From 4dc78cb82b5e8251c8f9f6784acae4fcea80581a Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sun, 17 May 2026 15:05:01 -0700 Subject: [PATCH 01/10] improvement(redis-cleanup): schedule, async workflow, hitl base64 cache cleanup (#4646) * improvement(redis-cleanup): schedule, async workflow, hitl bae64 cache cleanup * address comments --- apps/sim/background/schedule-execution.ts | 3 +++ apps/sim/background/workflow-execution.ts | 3 +++ apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts | 2 ++ 3 files changed, 8 insertions(+) diff --git a/apps/sim/background/schedule-execution.ts b/apps/sim/background/schedule-execution.ts index 7da80cab40f..5844dd3164d 100644 --- a/apps/sim/background/schedule-execution.ts +++ b/apps/sim/background/schedule-execution.ts @@ -21,6 +21,7 @@ import { import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' +import { cleanupExecutionBase64Cache } from '@/lib/uploads/utils/user-file-base64.server' import { executeWorkflowCore, wasExecutionFinalizedByCore, @@ -348,6 +349,8 @@ async function runWorkflowExecution({ }) throw error + } finally { + void cleanupExecutionBase64Cache(executionId) } } diff --git a/apps/sim/background/workflow-execution.ts b/apps/sim/background/workflow-execution.ts index 06d4e34180b..88b355d7d58 100644 --- a/apps/sim/background/workflow-execution.ts +++ b/apps/sim/background/workflow-execution.ts @@ -7,6 +7,7 @@ import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' +import { cleanupExecutionBase64Cache } from '@/lib/uploads/utils/user-file-base64.server' import { executeWorkflowCore, wasExecutionFinalizedByCore, @@ -196,6 +197,8 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) { }) throw error + } finally { + void cleanupExecutionBase64Cache(executionId) } }) } diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index 717a9f0fcc9..b2bf50c1d5a 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -16,6 +16,7 @@ import { import { compactBlockLogs, compactExecutionPayload } from '@/lib/execution/payloads/serializer' import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' +import { cleanupExecutionBase64Cache } from '@/lib/uploads/utils/user-file-base64.server' import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' import type { ExecutionEvent } from '@/lib/workflows/executor/execution-events' import { ExecutionSnapshot } from '@/executor/execution/snapshot' @@ -1363,6 +1364,7 @@ export class PauseResumeManager { }) }) } + void cleanupExecutionBase64Cache(resumeExecutionId) } if (executionError || !result) { From 42bbb8aa499bdbffb9b7754a3abbd8843fb4d278 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sun, 17 May 2026 15:30:42 -0700 Subject: [PATCH 02/10] improvement(mothership): abort path race preventing persistence (#4647) * improvement(mothership): abort path race preventing persistence * address comments * address bugbot comment --- .../app/api/copilot/chat/stop/route.test.ts | 123 ++++++++-- apps/sim/app/api/copilot/chat/stop/route.ts | 97 +++----- .../sim/lib/copilot/chat/persisted-message.ts | 39 +++ apps/sim/lib/copilot/chat/post.test.ts | 102 +++++++- apps/sim/lib/copilot/chat/post.ts | 32 ++- .../lib/copilot/chat/terminal-state.test.ts | 157 +++++++++--- apps/sim/lib/copilot/chat/terminal-state.ts | 160 +++++++++---- .../lib/copilot/request/lifecycle/run.test.ts | 225 ++++++++++++++++++ apps/sim/lib/copilot/request/lifecycle/run.ts | 51 ++-- 9 files changed, 811 insertions(+), 175 deletions(-) create mode 100644 apps/sim/lib/copilot/request/lifecycle/run.test.ts diff --git a/apps/sim/app/api/copilot/chat/stop/route.test.ts b/apps/sim/app/api/copilot/chat/stop/route.test.ts index a87f35a2987..bab5465507d 100644 --- a/apps/sim/app/api/copilot/chat/stop/route.test.ts +++ b/apps/sim/app/api/copilot/chat/stop/route.test.ts @@ -10,24 +10,49 @@ const { mockFrom, mockWhereSelect, mockLimit, + mockForUpdate, mockUpdate, mockSet, mockWhereUpdate, mockReturning, mockPublishStatusChanged, mockSql, -} = vi.hoisted(() => ({ - mockSelect: vi.fn(), - mockFrom: vi.fn(), - mockWhereSelect: vi.fn(), - mockLimit: vi.fn(), - mockUpdate: vi.fn(), - mockSet: vi.fn(), - mockWhereUpdate: vi.fn(), - mockReturning: vi.fn(), - mockPublishStatusChanged: vi.fn(), - mockSql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })), -})) + mockTransaction, +} = vi.hoisted(() => { + const mockSelect = vi.fn() + const mockFrom = vi.fn() + const mockWhereSelect = vi.fn() + const mockLimit = vi.fn() + const mockForUpdate = vi.fn() + const mockUpdate = vi.fn() + const mockSet = vi.fn() + const mockWhereUpdate = vi.fn() + const mockReturning = vi.fn() + const mockPublishStatusChanged = vi.fn() + const mockSql = vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ + strings, + values, + })) + const mockTransaction = vi.fn( + (callback: (tx: { select: typeof mockSelect; update: typeof mockUpdate }) => unknown) => + callback({ select: mockSelect, update: mockUpdate }) + ) + + return { + mockSelect, + mockFrom, + mockWhereSelect, + mockLimit, + mockForUpdate, + mockUpdate, + mockSet, + mockWhereUpdate, + mockReturning, + mockPublishStatusChanged, + mockSql, + mockTransaction, + } +}) vi.mock('@sim/db/schema', () => ({ copilotChats: { @@ -41,8 +66,7 @@ vi.mock('@sim/db/schema', () => ({ vi.mock('@sim/db', () => ({ db: { - select: mockSelect, - update: mockUpdate, + transaction: mockTransaction, }, })) @@ -78,9 +102,11 @@ describe('copilot chat stop route', () => { { workspaceId: 'ws-1', messages: [{ id: 'stream-1', role: 'user', content: 'hello' }], + conversationId: 'stream-1', }, ]) - mockWhereSelect.mockReturnValue({ limit: mockLimit }) + mockForUpdate.mockReturnValue({ limit: mockLimit }) + mockWhereSelect.mockReturnValue({ for: mockForUpdate }) mockFrom.mockReturnValue({ where: mockWhereSelect }) mockSelect.mockReturnValue({ from: mockFrom }) @@ -153,4 +179,71 @@ describe('copilot chat stop route', () => { streamId: 'stream-1', }) }) + + it('appends a stopped assistant message if the stream marker was already cleared', async () => { + mockLimit.mockResolvedValueOnce([ + { + workspaceId: 'ws-1', + messages: [{ id: 'stream-1', role: 'user', content: 'hello' }], + conversationId: null, + }, + ]) + + const response = await POST( + createRequest({ + chatId: 'chat-1', + streamId: 'stream-1', + content: 'partial', + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ success: true }) + + const setArg = mockSet.mock.calls[0]?.[0] + expect(setArg.messages).toBeTruthy() + const appendedPayload = JSON.parse(setArg.messages.values[1] as string) + expect(appendedPayload[0]).toMatchObject({ + role: 'assistant', + content: 'partial', + }) + + expect(mockPublishStatusChanged).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + chatId: 'chat-1', + type: 'completed', + streamId: 'stream-1', + }) + }) + + it('republishes completed status when the assistant was already persisted', async () => { + mockLimit.mockResolvedValueOnce([ + { + workspaceId: 'ws-1', + messages: [ + { id: 'stream-1', role: 'user', content: 'hello' }, + { id: 'assistant-1', role: 'assistant', content: 'partial' }, + ], + conversationId: null, + }, + ]) + + const response = await POST( + createRequest({ + chatId: 'chat-1', + streamId: 'stream-1', + content: 'partial', + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ success: true }) + expect(mockUpdate).not.toHaveBeenCalled() + expect(mockPublishStatusChanged).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + chatId: 'chat-1', + type: 'completed', + streamId: 'stream-1', + }) + }) }) diff --git a/apps/sim/app/api/copilot/chat/stop/route.ts b/apps/sim/app/api/copilot/chat/stop/route.ts index 36d3b8ae43d..05d7303d94c 100644 --- a/apps/sim/app/api/copilot/chat/stop/route.ts +++ b/apps/sim/app/api/copilot/chat/stop/route.ts @@ -1,14 +1,19 @@ -import { db } from '@sim/db' -import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { and, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { copilotChatStopContract } from '@/lib/api/contracts/copilot' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { normalizeMessage, type PersistedMessage } from '@/lib/copilot/chat/persisted-message' -import { CopilotStopOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' +import { + normalizeMessage, + type PersistedMessage, + withStoppedContentBlock, +} from '@/lib/copilot/chat/persisted-message' +import { finalizeAssistantTurn } from '@/lib/copilot/chat/terminal-state' +import { + CopilotChatFinalizeOutcome, + CopilotStopOutcome, +} from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { withIncomingGoSpan } from '@/lib/copilot/request/otel' @@ -44,81 +49,49 @@ export const POST = withRouteHandler((req: NextRequest) => ...(requestId ? { [TraceAttr.RequestId]: requestId } : {}), }) - const [row] = await db - .select({ - workspaceId: copilotChats.workspaceId, - messages: copilotChats.messages, - }) - .from(copilotChats) - .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id))) - .limit(1) - - if (!row) { - span.setAttribute(TraceAttr.CopilotStopOutcome, CopilotStopOutcome.ChatNotFound) - return NextResponse.json({ success: true }) - } - - const messages: Record[] = Array.isArray(row.messages) ? row.messages : [] - const userIdx = messages.findIndex((message) => message.id === streamId) - const alreadyHasResponse = - userIdx >= 0 && - userIdx + 1 < messages.length && - (messages[userIdx + 1] as Record)?.role === 'assistant' - const canAppendAssistant = - userIdx >= 0 && userIdx === messages.length - 1 && !alreadyHasResponse - - const updateWhere = and( - eq(copilotChats.id, chatId), - eq(copilotChats.userId, session.user.id), - eq(copilotChats.conversationId, streamId) - ) - - const setClause: Record = { - conversationId: null, - updatedAt: new Date(), - } - const hasContent = content.trim().length > 0 const hasBlocks = Array.isArray(contentBlocks) && contentBlocks.length > 0 - const synthesizedStoppedBlocks = hasBlocks + const assistantBlocks = hasBlocks ? contentBlocks : hasContent - ? [{ type: 'text', channel: 'assistant', content }, { type: 'stopped' }] - : [{ type: 'stopped' }] - if (canAppendAssistant) { - const normalized = normalizeMessage({ + ? [{ type: 'text', channel: 'assistant', content }] + : [] + const assistantMessage: PersistedMessage = withStoppedContentBlock( + normalizeMessage({ id: generateId(), role: 'assistant', content, timestamp: new Date().toISOString(), - contentBlocks: synthesizedStoppedBlocks, - // Persist so the UI copy-request-id button survives refetch. + contentBlocks: assistantBlocks, ...(requestId ? { requestId } : {}), }) - const assistantMessage: PersistedMessage = normalized - setClause.messages = sql`${copilotChats.messages} || ${JSON.stringify([assistantMessage])}::jsonb` - } - span.setAttribute(TraceAttr.CopilotStopAppendedAssistant, canAppendAssistant) - - const [updated] = await db - .update(copilotChats) - .set(setClause) - .where(updateWhere) - .returning({ workspaceId: copilotChats.workspaceId }) + ) + const result = await finalizeAssistantTurn({ + chatId, + userId: session.user.id, + userMessageId: streamId, + assistantMessage, + streamMarkerPolicy: 'active-or-cleared', + }) + span.setAttribute(TraceAttr.CopilotStopAppendedAssistant, result.appendedAssistant) + const stopOutcome = !result.found + ? CopilotStopOutcome.ChatNotFound + : result.updated || result.outcome === CopilotChatFinalizeOutcome.AssistantAlreadyPersisted + ? CopilotStopOutcome.Persisted + : CopilotStopOutcome.NoMatchingRow + const shouldPublishCompleted = + result.updated || result.outcome === CopilotChatFinalizeOutcome.AssistantAlreadyPersisted - if (updated?.workspaceId) { + if (shouldPublishCompleted && result.workspaceId) { taskPubSub?.publishStatusChanged({ - workspaceId: updated.workspaceId, + workspaceId: result.workspaceId, chatId, type: 'completed', streamId, }) } - span.setAttribute( - TraceAttr.CopilotStopOutcome, - updated ? CopilotStopOutcome.Persisted : CopilotStopOutcome.NoMatchingRow - ) + span.setAttribute(TraceAttr.CopilotStopOutcome, stopOutcome) return NextResponse.json({ success: true }) } catch (error) { logger.error('Error stopping chat stream:', error) diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts index 4629b591192..efde8aa104f 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -224,6 +224,45 @@ export function buildPersistedAssistantMessage( return message } +export function withStoppedContentBlock(message: PersistedMessage): PersistedMessage { + const contentBlocks = message.contentBlocks ?? [] + const hasAssistantText = contentBlocks.some( + (block) => + block.type === MothershipStreamV1EventType.text && + block.channel !== MothershipStreamV1TextChannel.thinking && + block.content?.trim() + ) + if ( + contentBlocks.some( + (block) => + block.type === MothershipStreamV1EventType.complete && + block.status === MothershipStreamV1CompletionStatus.cancelled + ) + ) { + return message + } + + return normalizeMessage({ + ...message, + contentBlocks: [ + ...(hasAssistantText || !message.content.trim() + ? [] + : [ + { + type: MothershipStreamV1EventType.text, + channel: MothershipStreamV1TextChannel.assistant, + content: message.content, + }, + ]), + ...contentBlocks, + { + type: MothershipStreamV1EventType.complete, + status: MothershipStreamV1CompletionStatus.cancelled, + }, + ], + }) +} + export interface UserMessageParams { id: string content: string diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index 1a6dee8a4b1..8b937704ac6 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -27,6 +27,8 @@ const { getPendingChatStreamId, releasePendingChatStream, resolveOrCreateChat, + finalizeAssistantTurn, + mockPublishStatusChanged, } = vi.hoisted(() => ({ getEffectiveDecryptedEnv: vi.fn(), generateWorkspaceContext: vi.fn(), @@ -38,6 +40,8 @@ const { getPendingChatStreamId: vi.fn(), releasePendingChatStream: vi.fn(), resolveOrCreateChat: vi.fn(), + finalizeAssistantTurn: vi.fn(), + mockPublishStatusChanged: vi.fn(), })) const getSession = authMockFns.mockGetSession @@ -78,9 +82,13 @@ vi.mock('@/lib/copilot/chat/lifecycle', () => ({ resolveOrCreateChat, })) +vi.mock('@/lib/copilot/chat/terminal-state', () => ({ + finalizeAssistantTurn, +})) + vi.mock('@/lib/copilot/tasks', () => ({ taskPubSub: { - publishStatusChanged: vi.fn(), + publishStatusChanged: mockPublishStatusChanged, }, })) @@ -137,6 +145,13 @@ describe('handleUnifiedChatPost', () => { conversationHistory: [], isNew: true, }) + finalizeAssistantTurn.mockResolvedValue({ + found: true, + updated: true, + appendedAssistant: true, + workspaceId: 'ws-1', + outcome: 'appended_assistant', + }) }) it('routes workflow-attached chat requests through the copilot backend path', async () => { @@ -176,6 +191,7 @@ describe('handleUnifiedChatPost', () => { body: JSON.stringify({ message: 'Hello', workspaceId: 'ws-1', + createNewChat: true, }), }) ) @@ -205,6 +221,90 @@ describe('handleUnifiedChatPost', () => { ) }) + it('persists cancelled partial responses from the server lifecycle', async () => { + await handleUnifiedChatPost( + new NextRequest('http://localhost/api/copilot/chat', { + method: 'POST', + body: JSON.stringify({ + message: 'Hello', + workspaceId: 'ws-1', + createNewChat: true, + }), + }) + ) + + const streamArgs = createSSEStream.mock.calls[0]?.[0] + const onComplete = streamArgs?.orchestrateOptions?.onComplete + expect(onComplete).toBeTypeOf('function') + + await onComplete({ + success: false, + cancelled: true, + content: 'partial answer', + contentBlocks: [], + toolCalls: [], + chatId: 'chat-1', + requestId: 'request-1', + }) + + expect(finalizeAssistantTurn).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: 'chat-1', + userMessageId: expect.any(String), + streamMarkerPolicy: 'active-or-cleared', + assistantMessage: expect.objectContaining({ + role: 'assistant', + content: 'partial answer', + contentBlocks: expect.arrayContaining([ + expect.objectContaining({ type: 'complete', status: 'cancelled' }), + ]), + }), + }) + ) + }) + + it('republishes completed status when cancelled lifecycle persistence already ran', async () => { + await handleUnifiedChatPost( + new NextRequest('http://localhost/api/copilot/chat', { + method: 'POST', + body: JSON.stringify({ + message: 'Hello', + workspaceId: 'ws-1', + createNewChat: true, + }), + }) + ) + + const streamArgs = createSSEStream.mock.calls[0]?.[0] + const onComplete = streamArgs?.orchestrateOptions?.onComplete + expect(onComplete).toBeTypeOf('function') + + finalizeAssistantTurn.mockResolvedValueOnce({ + found: true, + updated: false, + appendedAssistant: false, + workspaceId: 'ws-1', + outcome: 'assistant_already_persisted', + }) + + await onComplete({ + success: false, + cancelled: true, + content: 'partial answer', + contentBlocks: [], + toolCalls: [], + chatId: 'chat-1', + requestId: 'request-1', + }) + + expect(mockPublishStatusChanged).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + chatId: 'chat-1', + type: 'completed', + streamId: streamArgs?.streamId, + }) + }) + it('rejects requests that have neither workflow nor workspace attachment', async () => { const response = await handleUnifiedChatPost( new NextRequest('http://localhost/api/copilot/chat', { diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 9eaa77b43d3..a7ba4573879 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -14,6 +14,7 @@ import { buildCopilotRequestPayload } from '@/lib/copilot/chat/payload' import { buildPersistedAssistantMessage, buildPersistedUserMessage, + withStoppedContentBlock, } from '@/lib/copilot/chat/persisted-message' import { processContextsServer, @@ -23,6 +24,7 @@ import { finalizeAssistantTurn } from '@/lib/copilot/chat/terminal-state' import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' import { COPILOT_REQUEST_MODES } from '@/lib/copilot/constants' import { + CopilotChatFinalizeOutcome, CopilotChatPersistOutcome, CopilotTransport, } from '@/lib/copilot/generated/trace-attribute-values-v1' @@ -425,13 +427,31 @@ function buildOnComplete(params: { if (!chatId) return - // On cancel, /chat/stop is the sole DB writer — it persists - // partial content AND clears conversationId in one UPDATE. If we - // finalize here first the filter misses and content vanishes. - // Real errors still finalize so the stream marker clears. - if (result.cancelled) return - try { + if (result.cancelled) { + const finalization = await finalizeAssistantTurn({ + chatId, + userMessageId, + assistantMessage: withStoppedContentBlock( + buildPersistedAssistantMessage(result, requestId) + ), + streamMarkerPolicy: 'active-or-cleared', + }) + const shouldPublishCompletion = + finalization.updated || + finalization.outcome === CopilotChatFinalizeOutcome.AssistantAlreadyPersisted + + if (notifyWorkspaceStatus && workspaceId && shouldPublishCompletion) { + taskPubSub?.publishStatusChanged({ + workspaceId, + chatId, + type: 'completed', + streamId: userMessageId, + }) + } + return + } + await finalizeAssistantTurn({ chatId, userMessageId, diff --git a/apps/sim/lib/copilot/chat/terminal-state.test.ts b/apps/sim/lib/copilot/chat/terminal-state.test.ts index a9705573360..cf4a230bf31 100644 --- a/apps/sim/lib/copilot/chat/terminal-state.test.ts +++ b/apps/sim/lib/copilot/chat/terminal-state.test.ts @@ -3,36 +3,51 @@ */ import { copilotChats } from '@sim/db/schema' -import { and, eq } from 'drizzle-orm' +import { eq } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { selectLimit, selectWhere, selectFrom, select, updateWhere, updateSet, update } = vi.hoisted( - () => { - const selectLimit = vi.fn() - const selectWhere = vi.fn(() => ({ limit: selectLimit })) - const selectFrom = vi.fn(() => ({ where: selectWhere })) - const select = vi.fn(() => ({ from: selectFrom })) - - const updateWhere = vi.fn() - const updateSet = vi.fn(() => ({ where: updateWhere })) - const update = vi.fn(() => ({ set: updateSet })) - - return { - selectLimit, - selectWhere, - selectFrom, - select, - updateWhere, - updateSet, - update, - } +const { + selectForUpdate, + selectLimit, + selectWhere, + selectFrom, + select, + updateWhere, + updateSet, + update, + transaction, +} = vi.hoisted(() => { + const selectLimit = vi.fn() + const selectForUpdate = vi.fn(() => ({ limit: selectLimit })) + const selectWhere = vi.fn(() => ({ for: selectForUpdate })) + const selectFrom = vi.fn(() => ({ where: selectWhere })) + const select = vi.fn(() => ({ from: selectFrom })) + + const updateWhere = vi.fn() + const updateSet = vi.fn(() => ({ where: updateWhere })) + const update = vi.fn(() => ({ set: updateSet })) + + const transaction = vi.fn( + (callback: (tx: { select: typeof select; update: typeof update }) => unknown) => + callback({ select, update }) + ) + + return { + selectForUpdate, + selectLimit, + selectWhere, + selectFrom, + select, + updateWhere, + updateSet, + update, + transaction, } -) +}) vi.mock('@sim/db', () => ({ db: { - select, - update, + transaction, }, })) @@ -48,6 +63,8 @@ describe('finalizeAssistantTurn', () => { selectLimit.mockResolvedValue([ { messages: [{ id: 'user-1', role: 'user', content: 'hello' }], + conversationId: 'user-1', + workspaceId: 'ws-1', }, ]) @@ -69,9 +86,7 @@ describe('finalizeAssistantTurn', () => { messages: expect.anything(), }) ) - expect(updateWhere).toHaveBeenCalledWith( - and(eq(copilotChats.id, 'chat-1'), eq(copilotChats.conversationId, 'user-1')) - ) + expect(updateWhere).toHaveBeenCalledWith(eq(copilotChats.id, 'chat-1')) }) it('only clears the active stream marker when a response is already persisted', async () => { @@ -81,6 +96,8 @@ describe('finalizeAssistantTurn', () => { { id: 'user-1', role: 'user', content: 'hello' }, { id: 'assistant-1', role: 'assistant', content: 'partial' }, ], + conversationId: 'user-1', + workspaceId: 'ws-1', }, ]) @@ -108,8 +125,90 @@ describe('finalizeAssistantTurn', () => { }) ) expect(Object.hasOwn(updateArg, 'messages')).toBe(false) - expect(updateWhere).toHaveBeenCalledWith( - and(eq(copilotChats.id, 'chat-1'), eq(copilotChats.conversationId, 'user-1')) + expect(updateWhere).toHaveBeenCalledWith(eq(copilotChats.id, 'chat-1')) + }) + + it('appends a stopped assistant when the stream marker was already cleared', async () => { + selectLimit.mockResolvedValue([ + { + messages: [{ id: 'user-1', role: 'user', content: 'hello' }], + conversationId: null, + workspaceId: 'ws-1', + }, + ]) + + const result = await finalizeAssistantTurn({ + chatId: 'chat-1', + userMessageId: 'user-1', + streamMarkerPolicy: 'active-or-cleared', + assistantMessage: { + id: 'assistant-1', + role: 'assistant', + content: 'partial', + timestamp: '2024-01-01T00:00:00.000Z', + }, + }) + + expect(result.appendedAssistant).toBe(true) + expect(updateSet).toHaveBeenCalledWith( + expect.objectContaining({ + updatedAt: expect.any(Date), + conversationId: null, + messages: expect.anything(), + }) ) }) + + it('does not append on a cleared marker unless the policy allows it', async () => { + selectLimit.mockResolvedValue([ + { + messages: [{ id: 'user-1', role: 'user', content: 'hello' }], + conversationId: null, + workspaceId: 'ws-1', + }, + ]) + + const result = await finalizeAssistantTurn({ + chatId: 'chat-1', + userMessageId: 'user-1', + assistantMessage: { + id: 'assistant-1', + role: 'assistant', + content: 'partial', + timestamp: '2024-01-01T00:00:00.000Z', + }, + }) + + expect(result.updated).toBe(false) + expect(updateSet).not.toHaveBeenCalled() + }) + + it('reports already persisted when a cleared marker races with a duplicate stop', async () => { + selectLimit.mockResolvedValue([ + { + messages: [ + { id: 'user-1', role: 'user', content: 'hello' }, + { id: 'assistant-1', role: 'assistant', content: 'partial' }, + ], + conversationId: null, + workspaceId: 'ws-1', + }, + ]) + + const result = await finalizeAssistantTurn({ + chatId: 'chat-1', + userMessageId: 'user-1', + streamMarkerPolicy: 'active-or-cleared', + assistantMessage: { + id: 'assistant-2', + role: 'assistant', + content: 'partial', + timestamp: '2024-01-01T00:00:00.000Z', + }, + }) + + expect(result.updated).toBe(false) + expect(result.outcome).toBe('assistant_already_persisted') + expect(updateSet).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/copilot/chat/terminal-state.ts b/apps/sim/lib/copilot/chat/terminal-state.ts index f0f43cb6bb0..d9cada50168 100644 --- a/apps/sim/lib/copilot/chat/terminal-state.ts +++ b/apps/sim/lib/copilot/chat/terminal-state.ts @@ -7,10 +7,22 @@ import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { withCopilotSpan } from '@/lib/copilot/request/otel' +type StreamMarkerPolicy = 'active-only' | 'active-or-cleared' + interface FinalizeAssistantTurnParams { chatId: string userMessageId: string + userId?: string assistantMessage?: PersistedMessage + streamMarkerPolicy?: StreamMarkerPolicy +} + +export interface FinalizeAssistantTurnResult { + found: boolean + updated: boolean + appendedAssistant: boolean + workspaceId?: string | null + outcome: (typeof CopilotChatFinalizeOutcome)[keyof typeof CopilotChatFinalizeOutcome] } /** @@ -21,8 +33,10 @@ interface FinalizeAssistantTurnParams { export async function finalizeAssistantTurn({ chatId, userMessageId, + userId, assistantMessage, -}: FinalizeAssistantTurnParams): Promise { + streamMarkerPolicy = 'active-only', +}: FinalizeAssistantTurnParams): Promise { return withCopilotSpan( TraceSpan.CopilotChatFinalizeAssistantTurn, { @@ -33,55 +47,109 @@ export async function finalizeAssistantTurn({ [TraceAttr.ChatHasAssistantMessage]: !!assistantMessage, }, async (span) => { - const [row] = await db - .select({ messages: copilotChats.messages }) - .from(copilotChats) - .where(eq(copilotChats.id, chatId)) - .limit(1) + const result = await db.transaction(async (tx) => { + const where = userId + ? and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)) + : eq(copilotChats.id, chatId) + const [row] = await tx + .select({ + messages: copilotChats.messages, + conversationId: copilotChats.conversationId, + workspaceId: copilotChats.workspaceId, + }) + .from(copilotChats) + .where(where) + .for('update') + .limit(1) - const messages: Record[] = Array.isArray(row?.messages) ? row.messages : [] - span.setAttribute(TraceAttr.ChatExistingMessageCount, messages.length) - const userIdx = messages.findIndex((message) => message.id === userMessageId) - const alreadyHasResponse = - userIdx >= 0 && - userIdx + 1 < messages.length && - (messages[userIdx + 1] as Record)?.role === 'assistant' - const canAppendAssistant = - userIdx >= 0 && userIdx === messages.length - 1 && !alreadyHasResponse - const updateWhere = and( - eq(copilotChats.id, chatId), - eq(copilotChats.conversationId, userMessageId) - ) + const messages: Record[] = Array.isArray(row?.messages) ? row.messages : [] + span.setAttribute(TraceAttr.ChatExistingMessageCount, messages.length) - const baseUpdate = { - conversationId: null, - updatedAt: new Date(), - } + if (!row) { + return { + found: false, + updated: false, + appendedAssistant: false, + workspaceId: null, + outcome: CopilotChatFinalizeOutcome.StaleUserMessage, + } + } - if (assistantMessage && canAppendAssistant) { - await db - .update(copilotChats) - .set({ - ...baseUpdate, - messages: sql`${copilotChats.messages} || ${JSON.stringify([assistantMessage])}::jsonb`, - }) - .where(updateWhere) - span.setAttribute( - TraceAttr.ChatFinalizeOutcome, - CopilotChatFinalizeOutcome.AppendedAssistant - ) - return - } + const markerMatches = row.conversationId === userMessageId + const markerAlreadyCleared = row.conversationId === null + const ownsTurn = + markerMatches || (streamMarkerPolicy === 'active-or-cleared' && markerAlreadyCleared) + if (!ownsTurn) { + return { + found: true, + updated: false, + appendedAssistant: false, + workspaceId: row.workspaceId, + outcome: CopilotChatFinalizeOutcome.StaleUserMessage, + } + } + + const userIdx = messages.findIndex((message) => message.id === userMessageId) + const alreadyHasResponse = + userIdx >= 0 && + userIdx + 1 < messages.length && + (messages[userIdx + 1] as Record)?.role === 'assistant' + const canAppendAssistant = + userIdx >= 0 && userIdx === messages.length - 1 && !alreadyHasResponse + + const updateWhere = userId + ? and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)) + : eq(copilotChats.id, chatId) + const baseUpdate = { + conversationId: null, + updatedAt: new Date(), + } + + if (assistantMessage && canAppendAssistant) { + await tx + .update(copilotChats) + .set({ + ...baseUpdate, + messages: sql`${copilotChats.messages} || ${JSON.stringify([assistantMessage])}::jsonb`, + }) + .where(updateWhere) + return { + found: true, + updated: true, + appendedAssistant: true, + workspaceId: row.workspaceId, + outcome: CopilotChatFinalizeOutcome.AppendedAssistant, + } + } + + if (markerMatches) { + await tx.update(copilotChats).set(baseUpdate).where(updateWhere) + return { + found: true, + updated: true, + appendedAssistant: false, + workspaceId: row.workspaceId, + outcome: assistantMessage + ? alreadyHasResponse + ? CopilotChatFinalizeOutcome.AssistantAlreadyPersisted + : CopilotChatFinalizeOutcome.StaleUserMessage + : CopilotChatFinalizeOutcome.ClearedStreamMarkerOnly, + } + } + + return { + found: true, + updated: false, + appendedAssistant: false, + workspaceId: row.workspaceId, + outcome: alreadyHasResponse + ? CopilotChatFinalizeOutcome.AssistantAlreadyPersisted + : CopilotChatFinalizeOutcome.StaleUserMessage, + } + }) - await db.update(copilotChats).set(baseUpdate).where(updateWhere) - span.setAttribute( - TraceAttr.ChatFinalizeOutcome, - assistantMessage - ? alreadyHasResponse - ? 'assistant_already_persisted' - : 'stale_user_message' - : 'cleared_stream_marker_only' - ) + span.setAttribute(TraceAttr.ChatFinalizeOutcome, result.outcome) + return result } ) } diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts new file mode 100644 index 00000000000..31b9c4dad58 --- /dev/null +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -0,0 +1,225 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionContext, StreamingContext } from '@/lib/copilot/request/types' + +const { + mockCreateRunSegment, + mockGetEffectiveDecryptedEnv, + mockGetMothershipBaseURL, + mockGetMothershipSourceEnvHeaders, + mockPrepareExecutionContext, + mockRunStreamLoop, + mockUpdateRunStatus, +} = vi.hoisted(() => ({ + mockCreateRunSegment: vi.fn(), + mockGetEffectiveDecryptedEnv: vi.fn(), + mockGetMothershipBaseURL: vi.fn(), + mockGetMothershipSourceEnvHeaders: vi.fn(), + mockPrepareExecutionContext: vi.fn(), + mockRunStreamLoop: vi.fn(), + mockUpdateRunStatus: vi.fn(), +})) + +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + createRunSegment: mockCreateRunSegment, + updateRunStatus: mockUpdateRunStatus, +})) + +vi.mock('@/lib/copilot/request/go/stream', () => { + class CopilotBackendError extends Error { + status?: number + + constructor(message: string, options?: { status?: number }) { + super(message) + this.name = 'CopilotBackendError' + this.status = options?.status + } + } + + class BillingLimitError extends Error { + userId: string + + constructor(userId: string) { + super('Usage limit reached') + this.name = 'BillingLimitError' + this.userId = userId + } + } + + return { + BillingLimitError, + CopilotBackendError, + runStreamLoop: mockRunStreamLoop, + } +}) + +vi.mock('@/lib/copilot/server/agent-url', () => ({ + getMothershipBaseURL: mockGetMothershipBaseURL, + getMothershipSourceEnvHeaders: mockGetMothershipSourceEnvHeaders, +})) + +vi.mock('@/lib/core/config/env', () => ({ + env: { + COPILOT_API_KEY: undefined, + }, + getEnv: vi.fn((key: string) => (key === 'NEXT_PUBLIC_APP_URL' ? 'http://localhost:3000' : '')), + isTruthy: vi.fn((value: string | undefined) => value === 'true'), +})) + +vi.mock('@/lib/environment/utils', () => ({ + getEffectiveDecryptedEnv: mockGetEffectiveDecryptedEnv, +})) + +vi.mock('@/lib/copilot/tools/handlers/context', () => ({ + prepareExecutionContext: mockPrepareExecutionContext, +})) + +vi.mock('@/lib/copilot/request/tools/billing', () => ({ + handleBillingLimitResponse: vi.fn(), +})) + +vi.mock('@/lib/copilot/request/tools/executor', () => ({ + executeToolAndReport: vi.fn(), +})) + +import { runCopilotLifecycle } from '@/lib/copilot/request/lifecycle/run' + +describe('runCopilotLifecycle', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetMothershipBaseURL.mockResolvedValue('http://mothership.test') + mockGetMothershipSourceEnvHeaders.mockReturnValue({}) + }) + + it('runs cancelled completion persistence when a stream throws after abort', async () => { + const abortController = new AbortController() + abortController.abort('stop') + const onComplete = vi.fn() + const onError = vi.fn() + const executionContext: ExecutionContext = { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + chatId: 'chat-1', + decryptedEnvVars: {}, + } + + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + context.accumulatedContent = 'partial answer' + context.contentBlocks.push({ + type: 'text', + content: 'partial answer', + timestamp: 1, + }) + throw new Error('publisher closed after stop') + } + ) + + const result = await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-1' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + executionId: 'exec-1', + runId: 'run-1', + abortSignal: abortController.signal, + executionContext, + onComplete, + onError, + } + ) + + expect(onError).not.toHaveBeenCalled() + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + cancelled: true, + content: 'partial answer', + chatId: 'chat-1', + requestId: undefined, + error: 'publisher closed after stop', + contentBlocks: [ + expect.objectContaining({ + type: 'text', + content: 'partial answer', + }), + ], + }) + ) + expect(result).toEqual( + expect.objectContaining({ + success: false, + cancelled: true, + content: 'partial answer', + chatId: 'chat-1', + error: 'publisher closed after stop', + }) + ) + }) + + it('returns the cancelled result when cancelled completion persistence fails', async () => { + const abortController = new AbortController() + abortController.abort('stop') + const onComplete = vi.fn().mockRejectedValue(new Error('db unavailable')) + const onError = vi.fn() + const executionContext: ExecutionContext = { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + chatId: 'chat-1', + decryptedEnvVars: {}, + } + + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + context.accumulatedContent = 'partial answer' + throw new Error('publisher closed after stop') + } + ) + + const result = await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-1' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + executionId: 'exec-1', + runId: 'run-1', + abortSignal: abortController.signal, + executionContext, + onComplete, + onError, + } + ) + + expect(onError).not.toHaveBeenCalled() + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + cancelled: true, + content: 'partial answer', + }) + ) + expect(result).toEqual( + expect.objectContaining({ + success: false, + cancelled: true, + content: 'partial answer', + error: 'publisher closed after stop', + }) + ) + }) +}) diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index 141184b49b2..e06341f43ef 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -122,6 +122,7 @@ export async function runCopilotLifecycle( messageId: payloadMsgId, ...(lifecycleOptions.trace ? { trace: lifecycleOptions.trace } : {}), }) + let onCompleteStarted = false try { await runCheckpointLoop(requestPayload, context, execContext, lifecycleOptions, goRoute) @@ -129,9 +130,10 @@ export async function runCopilotLifecycle( const result: OrchestratorResult = { success: context.errors.length === 0 && !context.wasAborted, // `cancelled` is an explicit discriminator so callers can tell - // "user hit Stop" (don't clear the chat row; /chat/stop owns it) - // from "backend errored" (do clear the row so the chat isn't - // stuck with a non-null `conversationId`). An error that also + // "user hit Stop" (persist partial assistant content through the + // cancelled completion path) from "backend errored" (do clear the + // row so the chat isn't stuck with a non-null `conversationId`). + // An error that also // happens to fire the abort signal still counts as an error // path, but practically that doesn't happen in the success // branch here — if there are errors we never reach a @@ -146,34 +148,51 @@ export async function runCopilotLifecycle( usage: context.usage, cost: context.cost, } - await lifecycleOptions.onComplete?.(result) + if (lifecycleOptions.onComplete) { + onCompleteStarted = true + await lifecycleOptions.onComplete(result) + } return result } catch (error) { - const err = error instanceof Error ? error : new Error('Copilot orchestration failed') + const err = toError(error) logger.error('Copilot orchestration failed', { error: err.message }) // If the abort signal fired, this throw is a consequence of the // cancel (publisher.publish fails once the client disconnects, a // downstream Go read throws on ctx cancel, etc.) — NOT a real // backend error. Don't invoke `onError`, because on the cancel - // path `/api/copilot/chat/stop` is the single DB writer and - // `onError` would race with it via `finalizeAssistantTurn`, - // clearing `conversationId` before stop's UPDATE can match (see - // `buildOnComplete` in chat/post.ts for the full rationale). + // path `onComplete(cancelled)` persists partial content with an + // idempotent row-locked finalizer. `onError` would race with it via + // `finalizeAssistantTurn`, clearing `conversationId` before the + // partial content can be appended. // Return `cancelled: true` so upstream classification stays // consistent with the success-path cancel result. const wasCancelled = lifecycleOptions.abortSignal?.aborted ?? false - if (!wasCancelled) { - await lifecycleOptions.onError?.(err) - } - return { + const result: OrchestratorResult = { success: false, cancelled: wasCancelled, - content: '', - contentBlocks: [], - toolCalls: [], + content: wasCancelled ? context.accumulatedContent : '', + contentBlocks: wasCancelled ? context.contentBlocks : [], + toolCalls: wasCancelled ? buildToolCallSummaries(context) : [], chatId: context.chatId, + requestId: context.requestId, error: err.message, + errors: context.errors.length ? context.errors : undefined, + usage: context.usage, + cost: context.cost, + } + + if (!wasCancelled) { + await lifecycleOptions.onError?.(err) + } else if (!onCompleteStarted && lifecycleOptions.onComplete) { + try { + await lifecycleOptions.onComplete(result) + } catch (completeError) { + logger.error('Cancelled copilot completion callback failed', { + error: toError(completeError).message, + }) + } } + return result } } From 3979476432c8829cfb0d8ed8b53531f1a596e40b Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 18 May 2026 10:19:01 -0700 Subject: [PATCH 03/10] improvement(workspace): allocate more space to name column in resource tables (#4645) --- .../components/resource/resource.tsx | 24 ++++++++++++------- .../workspace/[workspaceId]/files/files.tsx | 10 ++++---- .../knowledge/[id]/[documentId]/document.tsx | 6 ++--- .../[workspaceId]/knowledge/[id]/base.tsx | 12 +++++----- .../[workspaceId]/knowledge/knowledge.tsx | 12 +++++----- .../app/workspace/[workspaceId]/logs/logs.tsx | 10 ++++---- .../scheduled-tasks/scheduled-tasks.tsx | 4 ++-- .../workspace/[workspaceId]/tables/tables.tsx | 10 ++++---- 8 files changed, 48 insertions(+), 40 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 931b59ea15a..b0a149b5f1e 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -23,6 +23,8 @@ export interface ResourceColumn { id: string header: string widthMultiplier?: number + /** Fixed pixel width. When set, the column is excluded from proportional sizing. */ + widthPx?: number } export interface ResourceCell { @@ -649,26 +651,32 @@ const ResourceColGroup = memo(function ResourceColGroup({ columns, hasCheckbox, }: ResourceColGroupProps) { - const weights = columns.map( - (col, colIdx) => (colIdx === 0 ? 2.5 : 1.0) * (col.widthMultiplier ?? 1) + const fixedPxTotal = columns.reduce((sum, col) => sum + (col.widthPx ?? 0), 0) + const flexibleWeights = columns.map((col, colIdx) => + col.widthPx ? 0 : (colIdx === 0 ? 2.5 : 1.0) * (col.widthMultiplier ?? 1) ) - const total = weights.reduce((s, w) => s + w, 0) + const flexibleTotal = flexibleWeights.reduce((s, w) => s + w, 0) + const reservedPx = fixedPxTotal + (hasCheckbox ? CHECKBOX_COLUMN_WIDTH_PX : 0) return ( {hasCheckbox && } {columns.map((col, colIdx) => { - const columnRatio = weights[colIdx] / total + if (col.widthPx) { + return + } + const columnRatio = flexibleTotal > 0 ? flexibleWeights[colIdx] / flexibleTotal : 0 const columnPercent = columnRatio * 100 - const checkboxOffset = CHECKBOX_COLUMN_WIDTH_PX * columnRatio + const reservedOffset = reservedPx * columnRatio return ( 0 + ? `calc(${columnPercent}% - ${reservedOffset}px)` + : `${columnPercent}%`, }} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index ec1147a8fee..6cf3f4f7285 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -113,11 +113,11 @@ const ACCEPT_ATTR = SUPPORTED_EXTENSIONS.map((ext) => `.${ext}`).join(',') const COLUMNS: ResourceColumn[] = [ { id: 'name', header: 'Name' }, - { id: 'size', header: 'Size' }, - { id: 'type', header: 'Type' }, - { id: 'created', header: 'Created' }, - { id: 'owner', header: 'Owner' }, - { id: 'updated', header: 'Last Updated' }, + { id: 'size', header: 'Size', widthPx: 110 }, + { id: 'type', header: 'Type', widthPx: 120 }, + { id: 'created', header: 'Created', widthPx: 150 }, + { id: 'owner', header: 'Owner', widthPx: 160 }, + { id: 'updated', header: 'Last Updated', widthPx: 150 }, ] const MIME_TYPE_LABELS: Record = { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index e7e38119d63..59dee5bbd92 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -127,9 +127,9 @@ function truncateContent(content: string, maxLength = 150, searchQuery = ''): st const CHUNK_COLUMNS: ResourceColumn[] = [ { id: 'content', header: 'Content' }, - { id: 'index', header: 'Index' }, - { id: 'tokens', header: 'Tokens' }, - { id: 'status', header: 'Status' }, + { id: 'index', header: 'Index', widthPx: 100 }, + { id: 'tokens', header: 'Tokens', widthPx: 100 }, + { id: 'status', header: 'Status', widthPx: 120 }, ] export function Document({ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 1652e6eb2d2..26489ab687d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -86,12 +86,12 @@ const DOCUMENTS_PER_PAGE = 50 const DOCUMENT_COLUMNS: ResourceColumn[] = [ { id: 'name', header: 'Name' }, - { id: 'size', header: 'Size' }, - { id: 'tokens', header: 'Tokens' }, - { id: 'chunks', header: 'Chunks' }, - { id: 'uploaded', header: 'Uploaded' }, - { id: 'status', header: 'Status' }, - { id: 'tags', header: 'Tags' }, + { id: 'size', header: 'Size', widthPx: 90 }, + { id: 'tokens', header: 'Tokens', widthPx: 90 }, + { id: 'chunks', header: 'Chunks', widthPx: 90 }, + { id: 'uploaded', header: 'Uploaded', widthPx: 140 }, + { id: 'status', header: 'Status', widthPx: 110 }, + { id: 'tags', header: 'Tags', widthPx: 160 }, ] interface KnowledgeBaseProps { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index decb6549af6..32e4d9e7451 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -43,12 +43,12 @@ interface KnowledgeBaseWithDocCount extends KnowledgeBaseData { const COLUMNS: ResourceColumn[] = [ { id: 'name', header: 'Name' }, - { id: 'documents', header: 'Documents' }, - { id: 'tokens', header: 'Tokens' }, - { id: 'connectors', header: 'Connectors' }, - { id: 'created', header: 'Created' }, - { id: 'owner', header: 'Owner' }, - { id: 'updated', header: 'Last Updated' }, + { id: 'documents', header: 'Documents', widthPx: 110 }, + { id: 'tokens', header: 'Tokens', widthPx: 90 }, + { id: 'connectors', header: 'Connectors', widthPx: 130 }, + { id: 'created', header: 'Created', widthPx: 140 }, + { id: 'owner', header: 'Owner', widthPx: 150 }, + { id: 'updated', header: 'Last Updated', widthPx: 140 }, ] const DATABASE_ICON = diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 7ddf9eec8f2..20413838aec 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -106,11 +106,11 @@ const REFRESH_SPINNER_DURATION_MS = 1000 as const const LOG_COLUMNS: ResourceColumn[] = [ { id: 'workflow', header: 'Workflow' }, - { id: 'date', header: 'Date' }, - { id: 'status', header: 'Status' }, - { id: 'cost', header: 'Cost' }, - { id: 'trigger', header: 'Trigger' }, - { id: 'duration', header: 'Duration' }, + { id: 'date', header: 'Date', widthPx: 160 }, + { id: 'status', header: 'Status', widthPx: 120 }, + { id: 'cost', header: 'Cost', widthPx: 100 }, + { id: 'trigger', header: 'Trigger', widthPx: 140 }, + { id: 'duration', header: 'Duration', widthPx: 110 }, ] interface LogSelectionState { diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx index e300cf3fe26..06182ff41d5 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx @@ -50,8 +50,8 @@ function getScheduleDescription(s: WorkspaceScheduleData) { const COLUMNS: ResourceColumn[] = [ { id: 'task', header: 'Task' }, { id: 'schedule', header: 'Schedule', widthMultiplier: 1.5 }, - { id: 'nextRun', header: 'Next Run' }, - { id: 'lastRun', header: 'Last Run' }, + { id: 'nextRun', header: 'Next Run', widthPx: 160 }, + { id: 'lastRun', header: 'Last Run', widthPx: 160 }, ] export function ScheduledTasks() { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 5cf881a2f4b..6bd2a04d796 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -49,11 +49,11 @@ const logger = createLogger('Tables') const COLUMNS: ResourceColumn[] = [ { id: 'name', header: 'Name' }, - { id: 'columns', header: 'Columns' }, - { id: 'rows', header: 'Rows' }, - { id: 'created', header: 'Created' }, - { id: 'owner', header: 'Owner' }, - { id: 'updated', header: 'Last Updated' }, + { id: 'columns', header: 'Columns', widthPx: 110 }, + { id: 'rows', header: 'Rows', widthPx: 100 }, + { id: 'created', header: 'Created', widthPx: 150 }, + { id: 'owner', header: 'Owner', widthPx: 160 }, + { id: 'updated', header: 'Last Updated', widthPx: 150 }, ] export function Tables() { From 268fa0e97e988383e56971d0437c963ac059dc58 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 18 May 2026 10:36:47 -0700 Subject: [PATCH 04/10] fix(knowledge): preserve scroll position when toggling tokenizer in chunk viewer (#4643) * fix(knowledge): preserve scroll position when toggling tokenizer in chunk viewer * fix(knowledge): skip scroll restore on initial mount of chunk editor * chore(dev): add dev:clean script to purge Turbopack cache --- .../components/chunk-editor/chunk-editor.tsx | 29 +++++++++++++++++-- apps/sim/package.json | 1 + 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx index d0f241f1fdc..0656d632bf5 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { Label, Switch } from '@/components/emcn' import { isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' @@ -50,6 +50,8 @@ export function ChunkEditor({ onCreated, }: ChunkEditorProps) { const textareaRef = useRef(null) + const tokenizedScrollRef = useRef(null) + const preservedScrollTopRef = useRef(0) const { mutateAsync: updateChunk } = useUpdateChunk() const { mutateAsync: createChunk } = useCreateChunk() @@ -170,6 +172,24 @@ export function ChunkEditor({ [saveRef] ) + const hasToggledTokenizerRef = useRef(false) + + const handleTokenizerChange = useCallback( + (value: boolean) => { + const source = tokenizerOn ? tokenizedScrollRef.current : textareaRef.current + preservedScrollTopRef.current = source?.scrollTop ?? 0 + hasToggledTokenizerRef.current = true + setTokenizerOn(value) + }, + [tokenizerOn] + ) + + useLayoutEffect(() => { + if (!hasToggledTokenizerRef.current) return + const target = tokenizerOn ? tokenizedScrollRef.current : textareaRef.current + if (target) target.scrollTop = preservedScrollTopRef.current + }, [tokenizerOn]) + const tokenStrings = useMemo(() => { if (!tokenizerOn || !editedContent) return [] return getTokenStrings(editedContent) @@ -196,7 +216,10 @@ export function ChunkEditor({ }} > {tokenizerOn ? ( -
+
{tokenStrings.map((token, index) => ( diff --git a/apps/sim/package.json b/apps/sim/package.json index f175df18d51..dc6f615477a 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -9,6 +9,7 @@ }, "scripts": { "dev": "next dev --port 3000", + "dev:clean": "rm -rf .next/dev/cache", "dev:webpack": "next dev --webpack", "load:workflow": "bun run load:workflow:baseline", "load:workflow:baseline": "BASE_URL=${BASE_URL:-http://localhost:3000} WARMUP_DURATION=${WARMUP_DURATION:-10} WARMUP_RATE=${WARMUP_RATE:-2} PEAK_RATE=${PEAK_RATE:-8} HOLD_DURATION=${HOLD_DURATION:-20} bunx artillery run scripts/load/workflow-concurrency.yml", From cf146931ddcdc0f4f617085f8ca3a2da7bb4a336 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 18 May 2026 11:58:25 -0700 Subject: [PATCH 05/10] improvement(memory): replace unbounded server caches with lru-cache to fix heap growth (#4652) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(memory): prune toolSchemaCache and semaphores to prevent heap growth toolSchemaCache (lib/copilot/chat/payload.ts): module-level Map keyed by userId:workspaceId never deleted expired entries, only checked TTL on read. With 100K+ unique user/workspace pairs each holding 50-200KB of tool schemas, this was the primary driver of the 24MB -> 25GB heap growth observed in CloudWatch. Add a setInterval sweep every 30s (matching the TTL) with .unref() so it does not prevent graceful shutdown. semaphores (lib/core/async-jobs/backends/database.ts): acquireSlot created Semaphore entries that releaseSlot never deleted. With per-execution UUID keys (e.g. scheduleJobId), each scheduled workflow run would add a permanent entry. Store the concurrency limit on the Semaphore struct and delete the entry from the Map when all slots are free and no waiters remain. validatorCache (lib/copilot/tools/server/generated-schema.ts): validated as bounded (93 tools x 2 schema kinds = 186 max entries, ~2-9MB). No fix needed. isolated-vm nativeContexts: validated as deferred GC, self-healed by worker rotation at MAX_EXECUTIONS_PER_WORKER=200. externalMB spikes trace to concurrent isolate heaps at peak load (128MB limit x active isolates), not a reference leak. No fix needed. * fix(memory): prune effectiveEnvCache and instrument cache sizes in telemetry effectiveEnvCache (lib/environment/utils.ts): same unbounded accumulation pattern as toolSchemaCache — module-level Map keyed by userId:workspaceId with a 15s TTL that is only checked on read, never proactively evicted. Adds a periodic sweep matching the TTL interval with .unref(). cache-registry (lib/monitoring/cache-registry.ts): lightweight registry so modules can expose their cache sizes to telemetry without coupling. toolSchemaCache and effectiveEnvCache both register on module load. memory-telemetry: emits cacheSizes in every Memory snapshot log so CloudWatch can confirm the caches stay bounded post-deploy. * improvement(memory): replace manual TTL Maps with lru-cache for toolSchemaCache and effectiveEnvCache Replaces the homegrown Map + setInterval sweep pattern with LRUCache from the lru-cache npm package, which is the standard Node.js solution for bounded in-process caching with TTL. Changes per cache: - Removes manual ToolSchemaCacheEntry / EffectiveEnvCacheEntry types - Removes setInterval sweep timers (and the .unref() boilerplate) - Removes the two-phase promise->value entry update inside the IIFE - Stores Promise directly — in-flight and resolved states share one type - max: 200 (toolSchemaCache) / max: 500 (effectiveEnvCache) as hard ceilings - TTL behaviour and concurrent-request deduplication are preserved exactly - cache-registry .size reporting works unchanged via lru-cache's .size prop * fix(memory): remove redundant waiters guard in releaseSlot --- apps/sim/lib/copilot/chat/payload.ts | 32 +++++--------- .../lib/core/async-jobs/backends/database.ts | 6 ++- apps/sim/lib/environment/utils.ts | 42 +++++++------------ apps/sim/lib/monitoring/cache-registry.ts | 13 ++++++ apps/sim/lib/monitoring/memory-telemetry.ts | 2 + apps/sim/package.json | 1 + bun.lock | 1 + 7 files changed, 48 insertions(+), 49 deletions(-) create mode 100644 apps/sim/lib/monitoring/cache-registry.ts diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index 1c882f3d4d1..4e3fdf34da7 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -1,10 +1,12 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { LRUCache } from 'lru-cache' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { isPaid } from '@/lib/billing/plan-helpers' import { getToolEntry } from '@/lib/copilot/tool-executor/router' import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import { isHosted } from '@/lib/core/config/feature-flags' +import { registerCache } from '@/lib/monitoring/cache-registry' import { buildMothershipToolsForRequest } from '@/lib/mothership/settings/runtime' import { trackChatUpload } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { tools } from '@/tools/registry' @@ -13,13 +15,12 @@ import { getLatestVersionTools, stripVersionSuffix } from '@/tools/utils' const logger = createLogger('CopilotChatPayload') const TOOL_SCHEMA_CACHE_TTL_MS = 30_000 -type ToolSchemaCacheEntry = { - expiresAt: number - value?: ToolSchema[] - promise?: Promise -} +const toolSchemaCache = new LRUCache>({ + max: 200, + ttl: TOOL_SCHEMA_CACHE_TTL_MS, +}) -const toolSchemaCache = new Map() +registerCache('toolSchemaCache', () => toolSchemaCache.size) interface BuildPayloadParams { message: string @@ -74,13 +75,10 @@ export async function buildIntegrationToolSchemas( workspaceId?: string ): Promise { const cacheKey = `${userId}:${workspaceId ?? ''}:${options.schemaSurface ?? 'copilot'}` - const now = Date.now() + const cached = toolSchemaCache.get(cacheKey) - if (cached?.value && cached.expiresAt > now) { - return cached.value.map((tool) => ({ ...tool, input_schema: { ...tool.input_schema } })) - } - if (cached?.promise) { - const tools = await cached.promise + if (cached) { + const tools = await cached return tools.map((tool) => ({ ...tool, input_schema: { ...tool.input_schema } })) } @@ -187,18 +185,10 @@ export async function buildIntegrationToolSchemas( ) } - toolSchemaCache.set(cacheKey, { - value: integrationTools, - expiresAt: Date.now() + TOOL_SCHEMA_CACHE_TTL_MS, - }) - return integrationTools })() - toolSchemaCache.set(cacheKey, { - expiresAt: now + TOOL_SCHEMA_CACHE_TTL_MS, - promise, - }) + toolSchemaCache.set(cacheKey, promise) const integrationTools = await promise return integrationTools.map((tool) => ({ ...tool, input_schema: { ...tool.input_schema } })) diff --git a/apps/sim/lib/core/async-jobs/backends/database.ts b/apps/sim/lib/core/async-jobs/backends/database.ts index afaa44a3641..0350d57aa6c 100644 --- a/apps/sim/lib/core/async-jobs/backends/database.ts +++ b/apps/sim/lib/core/async-jobs/backends/database.ts @@ -38,6 +38,7 @@ function rowToJob(row: AsyncJobRow): Job { const inlineAbortControllers = new Map() interface Semaphore { + limit: number available: number waiters: Array<() => void> } @@ -46,7 +47,7 @@ const semaphores = new Map() async function acquireSlot(key: string, limit: number): Promise { let s = semaphores.get(key) if (!s) { - s = { available: limit, waiters: [] } + s = { limit, available: limit, waiters: [] } semaphores.set(key, s) } if (s.available > 0) { @@ -65,6 +66,9 @@ function releaseSlot(key: string): void { return } s.available += 1 + if (s.available === s.limit) { + semaphores.delete(key) + } } export class DatabaseJobQueue implements JobQueueBackend { diff --git a/apps/sim/lib/environment/utils.ts b/apps/sim/lib/environment/utils.ts index 509dcfd7667..e511bbd9002 100644 --- a/apps/sim/lib/environment/utils.ts +++ b/apps/sim/lib/environment/utils.ts @@ -4,24 +4,25 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { eq, inArray } from 'drizzle-orm' +import { LRUCache } from 'lru-cache' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { createWorkspaceEnvCredentials, getAccessibleEnvCredentials, syncPersonalEnvCredentialsForUser, } from '@/lib/credentials/environment' +import { registerCache } from '@/lib/monitoring/cache-registry' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('EnvironmentUtils') const EFFECTIVE_ENV_CACHE_TTL_MS = 15_000 -type EffectiveEnvCacheEntry = { - expiresAt: number - value?: Record - promise?: Promise> -} +const effectiveEnvCache = new LRUCache>>({ + max: 500, + ttl: EFFECTIVE_ENV_CACHE_TTL_MS, +}) -const effectiveEnvCache = new Map() +registerCache('effectiveEnvCache', () => effectiveEnvCache.size) function getEffectiveEnvCacheKey(userId: string, workspaceId?: string) { return `${userId}:${workspaceId ?? ''}` @@ -325,37 +326,24 @@ export async function getEffectiveDecryptedEnv( workspaceId?: string ): Promise> { const cacheKey = getEffectiveEnvCacheKey(userId, workspaceId) - const now = Date.now() - const cached = effectiveEnvCache.get(cacheKey) - - if (cached?.value && cached.expiresAt > now) { - return { ...cached.value } - } - if (cached?.promise) { - const value = await cached.promise + const cached = effectiveEnvCache.get(cacheKey) + if (cached) { + const value = await cached return { ...value } } const promise = getPersonalAndWorkspaceEnv(userId, workspaceId) - .then(({ personalDecrypted, workspaceDecrypted }) => { - const value = { ...personalDecrypted, ...workspaceDecrypted } - effectiveEnvCache.set(cacheKey, { - value, - expiresAt: Date.now() + EFFECTIVE_ENV_CACHE_TTL_MS, - }) - return value - }) + .then(({ personalDecrypted, workspaceDecrypted }) => ({ + ...personalDecrypted, + ...workspaceDecrypted, + })) .catch((error) => { effectiveEnvCache.delete(cacheKey) throw error }) - effectiveEnvCache.set(cacheKey, { - expiresAt: now + EFFECTIVE_ENV_CACHE_TTL_MS, - promise, - }) - + effectiveEnvCache.set(cacheKey, promise) const value = await promise return { ...value } } diff --git a/apps/sim/lib/monitoring/cache-registry.ts b/apps/sim/lib/monitoring/cache-registry.ts new file mode 100644 index 00000000000..19b8753b444 --- /dev/null +++ b/apps/sim/lib/monitoring/cache-registry.ts @@ -0,0 +1,13 @@ +const registry = new Map number>() + +export function registerCache(name: string, getSize: () => number): void { + registry.set(name, getSize) +} + +export function getCacheSizes(): Record { + const sizes: Record = {} + for (const [name, getSize] of registry) { + sizes[name] = getSize() + } + return sizes +} diff --git a/apps/sim/lib/monitoring/memory-telemetry.ts b/apps/sim/lib/monitoring/memory-telemetry.ts index 2845ee1def2..f80a0189dce 100644 --- a/apps/sim/lib/monitoring/memory-telemetry.ts +++ b/apps/sim/lib/monitoring/memory-telemetry.ts @@ -5,6 +5,7 @@ import v8 from 'node:v8' import { createLogger } from '@sim/logger' +import { getCacheSizes } from '@/lib/monitoring/cache-registry' const logger = createLogger('MemoryTelemetry', { logLevel: 'INFO' }) @@ -33,6 +34,7 @@ export function startMemoryTelemetry(intervalMs = 60_000) { ? process.getActiveResourcesInfo().length : -1, uptimeMin: Math.round(process.uptime() / 60), + cacheSizes: getCacheSizes(), }) }, intervalMs) timer.unref() diff --git a/apps/sim/package.json b/apps/sim/package.json index dc6f615477a..47e10e51490 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -148,6 +148,7 @@ "json5": "2.2.3", "jszip": "3.10.1", "jwt-decode": "^4.0.0", + "lru-cache": "11.3.6", "lucide-react": "^0.479.0", "mammoth": "^1.9.0", "mermaid": "11.15.0", diff --git a/bun.lock b/bun.lock index 92f6719f88d..d2dc430861b 100644 --- a/bun.lock +++ b/bun.lock @@ -202,6 +202,7 @@ "json5": "2.2.3", "jszip": "3.10.1", "jwt-decode": "^4.0.0", + "lru-cache": "11.3.6", "lucide-react": "^0.479.0", "mammoth": "^1.9.0", "mermaid": "11.15.0", From b27667286795516a4cd9762e6c813a270c6f43d0 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 18 May 2026 12:08:00 -0700 Subject: [PATCH 06/10] feat(prospeo): add Prospeo integration for B2B contact enrichment and search (#4653) * feat(prospeo): add Prospeo integration for B2B contact enrichment and search Adds 8 operations: enrich person/company, bulk enrich person/company, search person/company, search suggestions, and account information. Uses X-KEY header auth. * refactor(prospeo): extract shared parse helpers into utils.ts --- apps/docs/components/icons.tsx | 58 +++ apps/docs/components/ui/icon-mapping.ts | 4 +- .../docs/content/docs/en/tools/cloudwatch.mdx | 48 ++ apps/docs/content/docs/en/tools/file.mdx | 39 +- apps/docs/content/docs/en/tools/meta.json | 1 + apps/docs/content/docs/en/tools/prospeo.mdx | 237 ++++++++++ .../integrations/data/icon-mapping.ts | 3 +- .../integrations/data/integrations.json | 65 ++- apps/sim/blocks/blocks/prospeo.ts | 441 ++++++++++++++++++ apps/sim/blocks/registry.ts | 2 + apps/sim/components/icons.tsx | 35 ++ apps/sim/tools/prospeo/account_information.ts | 78 ++++ apps/sim/tools/prospeo/bulk_enrich_company.ts | 87 ++++ apps/sim/tools/prospeo/bulk_enrich_person.ts | 118 +++++ apps/sim/tools/prospeo/enrich_company.ts | 93 ++++ apps/sim/tools/prospeo/enrich_person.ts | 158 +++++++ apps/sim/tools/prospeo/index.ts | 17 + apps/sim/tools/prospeo/search_company.ts | 87 ++++ apps/sim/tools/prospeo/search_person.ts | 89 ++++ apps/sim/tools/prospeo/search_suggestions.ts | 95 ++++ apps/sim/tools/prospeo/types.ts | 193 ++++++++ apps/sim/tools/prospeo/utils.ts | 27 ++ apps/sim/tools/registry.ts | 18 + 23 files changed, 1978 insertions(+), 15 deletions(-) create mode 100644 apps/docs/content/docs/en/tools/prospeo.mdx create mode 100644 apps/sim/blocks/blocks/prospeo.ts create mode 100644 apps/sim/tools/prospeo/account_information.ts create mode 100644 apps/sim/tools/prospeo/bulk_enrich_company.ts create mode 100644 apps/sim/tools/prospeo/bulk_enrich_person.ts create mode 100644 apps/sim/tools/prospeo/enrich_company.ts create mode 100644 apps/sim/tools/prospeo/enrich_person.ts create mode 100644 apps/sim/tools/prospeo/index.ts create mode 100644 apps/sim/tools/prospeo/search_company.ts create mode 100644 apps/sim/tools/prospeo/search_person.ts create mode 100644 apps/sim/tools/prospeo/search_suggestions.ts create mode 100644 apps/sim/tools/prospeo/types.ts create mode 100644 apps/sim/tools/prospeo/utils.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 9be42a362eb..6f07869ffb3 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -1455,6 +1455,41 @@ export function ProfoundIcon(props: SVGProps) { ) } +export function ProspeoIcon(props: SVGProps) { + return ( + + + + + + + + + ) +} + export function PineconeIcon(props: SVGProps) { return ( ) { ) } + +export function BigQueryIcon(props: SVGProps) { + return ( + + + + + ) +} + +export function SnowflakeIcon(props: SVGProps) { + return ( + + + + ) +} diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 609f37382d7..0cb344dafc1 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -143,6 +143,7 @@ import { PostgresIcon, PosthogIcon, ProfoundIcon, + ProspeoIcon, PulseIcon, QdrantIcon, QuiverIcon, @@ -261,7 +262,7 @@ export const blockTypeToIconMap: Record = { extend_v2: ExtendIcon, fathom: FathomIcon, file: DocumentIcon, - file_v3: DocumentIcon, + file_v4: DocumentIcon, firecrawl: FirecrawlIcon, fireflies: FirefliesIcon, fireflies_v2: FirefliesIcon, @@ -360,6 +361,7 @@ export const blockTypeToIconMap: Record = { postgresql: PostgresIcon, posthog: PosthogIcon, profound: ProfoundIcon, + prospeo: ProspeoIcon, pulse: PulseIcon, pulse_v2: PulseIcon, qdrant: QdrantIcon, diff --git a/apps/docs/content/docs/en/tools/cloudwatch.mdx b/apps/docs/content/docs/en/tools/cloudwatch.mdx index af0fc0a2b0e..3f0380743c8 100644 --- a/apps/docs/content/docs/en/tools/cloudwatch.mdx +++ b/apps/docs/content/docs/en/tools/cloudwatch.mdx @@ -257,4 +257,52 @@ List and filter CloudWatch alarms | ↳ `threshold` | number | Threshold value \(MetricAlarm only\) | | ↳ `stateUpdatedTimestamp` | number | Epoch ms when state last changed | +### `cloudwatch_mute_alarm` + +Create a CloudWatch alarm mute rule that suppresses alarms for a fixed duration + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `muteRuleName` | string | Yes | Unique name for the mute rule \(used later to unmute\) | +| `alarmNames` | array | Yes | Names of the CloudWatch alarms this mute rule targets | +| `durationValue` | number | Yes | How long the mute lasts \(paired with durationUnit\) | +| `durationUnit` | string | Yes | Unit for durationValue: minutes, hours, or days | +| `description` | string | No | Optional description of why the alarms are being muted | +| `startDate` | number | No | When the mute window begins as Unix epoch seconds. Defaults to now \(mute starts immediately\). | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the mute rule was created successfully | +| `muteRuleName` | string | Name of the mute rule that was created | +| `alarmNames` | array | Names of the alarms this rule mutes | +| `expression` | string | Schedule expression used by the mute rule | +| `duration` | string | ISO 8601 duration of the mute window | + +### `cloudwatch_unmute_alarm` + +Delete a CloudWatch alarm mute rule, restoring alarm notifications + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) | +| `awsAccessKeyId` | string | Yes | AWS access key ID | +| `awsSecretAccessKey` | string | Yes | AWS secret access key | +| `muteRuleName` | string | Yes | Name of the mute rule to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the mute rule was deleted successfully | +| `muteRuleName` | string | Name of the mute rule that was deleted | + diff --git a/apps/docs/content/docs/en/tools/file.mdx b/apps/docs/content/docs/en/tools/file.mdx index 2a13095afdd..d167ae3f8a7 100644 --- a/apps/docs/content/docs/en/tools/file.mdx +++ b/apps/docs/content/docs/en/tools/file.mdx @@ -1,12 +1,12 @@ --- title: File -description: Read and write workspace files +description: Read, fetch, write, and append files --- import { BlockInfoCard } from "@/components/ui/block-info-card" @@ -27,30 +27,49 @@ The File Parser tool is particularly useful for scenarios where your agents need ## Usage Instructions -Read and parse files from uploads or URLs, write new workspace files, or append content to existing files. +Read workspace files by picker or canonical ID, fetch and parse files from URLs with optional headers, write new workspace files, or append content to existing files. ## Tools -### `file_parser` +### `file_fetch` -Parse one or more uploaded files or files from URLs (text, PDF, CSV, images, etc.) +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `file` | file | First workspace file object \(read\) | +| `files` | file[] | Workspace file objects \(read\) or fetched file objects \(fetch\) | +| `combinedContent` | string | All fetched file contents merged into a single text string \(fetch\) | +| `id` | string | File ID \(write\) | +| `name` | string | File name \(write\) | +| `size` | number | File size in bytes \(write\) | +| `url` | string | URL to access the file \(write\) | + +### `file_read` #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `filePath` | string | No | Path to the file\(s\). Can be a single path, URL, or an array of paths. | -| `file` | file | No | Uploaded file\(s\) to parse | -| `fileType` | string | No | Type of file to parse \(auto-detected if not specified\) | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `files` | file[] | Parsed files as UserFile objects | -| `combinedContent` | string | Combined content of all parsed files | +| `file` | file | First workspace file object \(read\) | +| `files` | file[] | Workspace file objects \(read\) or fetched file objects \(fetch\) | +| `combinedContent` | string | All fetched file contents merged into a single text string \(fetch\) | +| `id` | string | File ID \(write\) | +| `name` | string | File name \(write\) | +| `size` | number | File size in bytes \(write\) | +| `url` | string | URL to access the file \(write\) | ### `file_write` diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index ad0f6b437ad..e3fd30b75e9 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -139,6 +139,7 @@ "postgresql", "posthog", "profound", + "prospeo", "pulse", "qdrant", "quiver", diff --git a/apps/docs/content/docs/en/tools/prospeo.mdx b/apps/docs/content/docs/en/tools/prospeo.mdx new file mode 100644 index 00000000000..2072046a4a3 --- /dev/null +++ b/apps/docs/content/docs/en/tools/prospeo.mdx @@ -0,0 +1,237 @@ +--- +title: Prospeo +description: Enrich and search B2B contacts and companies +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Prospeo](https://prospeo.io/) is a B2B data platform that finds verified work emails and mobile numbers, enriches person and company profiles, and exposes a searchable database of leads and companies through 20+ filters. + +With Prospeo, you can: + +- **Find verified contact details**: Discover work emails and mobile numbers from a name, LinkedIn URL, or company +- **Enrich people and companies**: Fill in profile data including job history, location, technology stack, and funding signals +- **Bulk-enrich at scale**: Process arrays of identifiers in a single request for high-volume workflows +- **Search the B2B database**: Filter leads and accounts by job title, location, industry, headcount, and more +- **Monitor account usage**: Check remaining credits, used credits, and renewal dates before running expensive operations + +In Sim, the Prospeo integration lets your agents perform contact discovery and CRM enrichment programmatically: + +- **Enrich a person**: Use `prospeo_enrich_person` to match a contact and return verified email, mobile, and profile data +- **Enrich a company**: Use `prospeo_enrich_company` to resolve a domain, LinkedIn URL, or name into a full company profile +- **Bulk enrichment**: Use `prospeo_bulk_enrich_person` and `prospeo_bulk_enrich_company` for batched workflows +- **Search the database**: Use `prospeo_search_person` and `prospeo_search_company` with JSON filter objects to surface leads +- **Build dynamic filter UIs**: Use `prospeo_search_suggestions` to autocomplete locations and job titles +- **Track usage**: Use `prospeo_account_information` to monitor credits and plan status from within a workflow + +This turns Prospeo into a reliable contact and company data source for your agents — wire it into sales prospecting, CRM hygiene, lead scoring, or any pipeline that depends on accurate B2B identity data. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Find verified work emails and mobile numbers, enrich person and company profiles, and search a B2B database of leads and companies using 20+ filters. + + + +## Tools + +### `prospeo_account_information` + +Retrieve the current plan, remaining credits, and renewal date of your Prospeo account. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Prospeo API key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `current_plan` | string | Current Prospeo plan name | +| `current_team_members` | number | Number of team members in your team | +| `remaining_credits` | number | Number of credits remaining | +| `used_credits` | number | Number of credits already used | +| `next_quota_renewal_days` | number | Days until the next quota renewal | +| `next_quota_renewal_date` | string | Date and time of the next quota renewal | + +### `prospeo_enrich_person` + +Enrich a person with complete B2B profile data, email address and mobile. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Prospeo API key | +| `first_name` | string | No | First name of the person | +| `last_name` | string | No | Last name of the person | +| `full_name` | string | No | Full name of the person \(alternative to first_name + last_name\) | +| `linkedin_url` | string | No | Person's public LinkedIn URL | +| `email` | string | No | Work email of the person | +| `company_name` | string | No | Company name | +| `company_website` | string | No | Company website | +| `company_linkedin_url` | string | No | Company's public LinkedIn URL | +| `person_id` | string | No | Prospeo person_id from a previous Search Person response | +| `only_verified_email` | boolean | No | Only return records with a verified email | +| `enrich_mobile` | boolean | No | Reveal mobile number \(10 credits per match; email included\) | +| `only_verified_mobile` | boolean | No | Only return records that have a mobile \(implies enrich_mobile\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `free_enrichment` | boolean | True if this enrichment was free \(already enriched in the past\) | +| `person` | json | The matched person object including person_id, name, linkedin_url, current_job_title, job_history, mobile, email, location, and skills | +| `company` | json | The current company of the matched person including name, website, domain, industry, employee_count, location, social URLs, funding, and technology | + +### `prospeo_enrich_company` + +Enrich a company with complete B2B data. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Prospeo API key | +| `company_website` | string | No | Company website \(e.g., "intercom.com"\) | +| `company_linkedin_url` | string | No | Company's public LinkedIn URL | +| `company_name` | string | No | Company name \(use combined with website for best accuracy\) | +| `company_id` | string | No | Prospeo company_id from a previously enriched company | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `free_enrichment` | boolean | True if this enrichment was free \(already enriched in the past\) | +| `company` | json | The matched company object including name, website, domain, industry, employee_count, location, social URLs, funding, and technology | + +### `prospeo_bulk_enrich_person` + +Enrich up to 50 person records at once. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Prospeo API key | +| `data` | json | Yes | Array of up to 50 person records to enrich. Each must include an "identifier" plus one of: linkedin_url, email, person_id, or \(first_name + last_name + company_*\), or \(full_name + company_*\). | +| `only_verified_email` | boolean | No | Only return records with a verified email | +| `enrich_mobile` | boolean | No | Reveal mobile numbers \(10 credits per match; email included\) | +| `only_verified_mobile` | boolean | No | Only return records that have a mobile \(implies enrich_mobile\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `total_cost` | number | Total credits spent by the request | +| `matched` | array | Matched records \(identifier, person, company\) | +| ↳ `identifier` | string | The identifier you submitted for this record | +| ↳ `person` | json | The matched person object | +| ↳ `company` | json | The current company of the matched person | +| `not_matched` | array | Identifiers of records we could not match given the filters | +| `invalid_datapoints` | array | Identifiers of records that did not meet the minimum matching requirements | + +### `prospeo_bulk_enrich_company` + +Enrich up to 50 company records at once. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Prospeo API key | +| `data` | json | Yes | Array of up to 50 company records to enrich. Each must include an "identifier" plus one of: company_website, company_linkedin_url, company_name, or company_id. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `total_cost` | number | Total credits spent by the request | +| `matched` | array | Matched company records \(identifier, company\) | +| ↳ `identifier` | string | The identifier you submitted for this record | +| ↳ `company` | json | The matched company object | +| `not_matched` | array | Identifiers of records we could not match | +| `invalid_datapoints` | array | Identifiers of records that did not meet the minimum matching requirements | + +### `prospeo_search_person` + +Search for leads using 20+ filters to build targeted contact lists. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Prospeo API key | +| `filters` | json | Yes | Filter configuration object. See https://prospeo.io/api-docs/filters-documentation for all supported filters \(e.g., person_seniority, company_industry, person_location\). | +| `page` | number | No | Page number \(defaults to 1\). Up to 1000 pages of 25 results. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `pagination` | object | Pagination details | +| ↳ `current_page` | number | Current page number | +| ↳ `per_page` | number | Results per page | +| ↳ `total_page` | number | Total number of pages | +| ↳ `total_count` | number | Total number of matching records | +| `free` | boolean | True if the request was free due to 30-day result-set deduplication | +| `results` | array | Up to 25 search results \(person + company, no email/mobile\) | +| ↳ `person` | json | Matched person \(no email/mobile in search response\) | +| ↳ `company` | json | Current company of the person | + +### `prospeo_search_company` + +Search for companies using 20+ filters to build account lists. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Prospeo API key | +| `filters` | json | Yes | Filter configuration object. See https://prospeo.io/api-docs/filters-documentation for all supported filters \(e.g., company_industry, company_headcount_range, company_funding\). | +| `page` | number | No | Page number \(defaults to 1\). Up to 1000 pages of 25 results. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `pagination` | object | Pagination details | +| ↳ `current_page` | number | Current page number | +| ↳ `per_page` | number | Results per page | +| ↳ `total_page` | number | Total number of pages | +| ↳ `total_count` | number | Total number of matching records | +| `free` | boolean | True if the request was free due to 30-day result-set deduplication | +| `results` | array | Up to 25 matching companies | +| ↳ `company` | json | Matched company object | + +### `prospeo_search_suggestions` + +Free endpoint to retrieve valid location or job title values for use in Search filters. Provide exactly one of location_search or job_title_search. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Prospeo API key | +| `location_search` | string | No | Search query for location suggestions \(minimum 2 characters\). Mutually exclusive with job_title_search. | +| `job_title_search` | string | No | Search query for job title suggestions \(minimum 2 characters\). Mutually exclusive with location_search. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `location_suggestions` | array | Location suggestions when using location_search \(null when searching job titles\) | +| ↳ `name` | string | Formatted location name to use in filters | +| ↳ `type` | string | Location type \(COUNTRY, STATE, CITY, or ZONE\) | +| `job_title_suggestions` | array | Up to 25 job title suggestions ordered by popularity when using job_title_search \(null when searching locations\) | + + diff --git a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts index c4b0dfba957..dbae80a2539 100644 --- a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts +++ b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts @@ -143,6 +143,7 @@ import { PostgresIcon, PosthogIcon, ProfoundIcon, + ProspeoIcon, PulseIcon, QdrantIcon, QuiverIcon, @@ -257,7 +258,6 @@ export const blockTypeToIconMap: Record = { exa: ExaAIIcon, extend_v2: ExtendIcon, fathom: FathomIcon, - file_v3: DocumentIcon, file_v4: DocumentIcon, firecrawl: FirecrawlIcon, fireflies_v2: FirefliesIcon, @@ -345,6 +345,7 @@ export const blockTypeToIconMap: Record = { postgresql: PostgresIcon, posthog: PosthogIcon, profound: ProfoundIcon, + prospeo: ProspeoIcon, pulse_v2: PulseIcon, qdrant: QdrantIcon, quiver: QuiverIcon, diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index 0ba46960d75..9d33239f918 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -2540,9 +2540,17 @@ { "name": "Describe Alarms", "description": "List and filter CloudWatch alarms" + }, + { + "name": "Mute Alarm", + "description": "Create a CloudWatch alarm mute rule that suppresses alarms for a fixed duration" + }, + { + "name": "Unmute Alarm", + "description": "Delete a CloudWatch alarm mute rule, restoring alarm notifications" } ], - "operationCount": 8, + "operationCount": 10, "triggers": [], "triggerCount": 0, "authType": "none", @@ -4043,11 +4051,11 @@ "operations": [ { "name": "Read", - "description": "Get a workspace file object from a selected file or canonical workspace file ID." + "description": "Read workspace file objects from selected files or canonical workspace file IDs." }, { "name": "Fetch", - "description": "Parse a file from a URL with optional custom headers for authenticated downloads." + "description": "Fetch and parse a file from a URL with optional custom headers." }, { "name": "Write", @@ -10387,6 +10395,57 @@ "integrationTypes": ["analytics", "search"], "tags": ["seo", "data-analytics"] }, + { + "type": "prospeo", + "slug": "prospeo", + "name": "Prospeo", + "description": "Enrich and search B2B contacts and companies", + "longDescription": "Find verified work emails and mobile numbers, enrich person and company profiles, and search a B2B database of leads and companies using 20+ filters.", + "bgColor": "#FF1A26", + "iconName": "ProspeoIcon", + "docsUrl": "https://docs.sim.ai/tools/prospeo", + "operations": [ + { + "name": "Enrich Person", + "description": "Enrich a person with complete B2B profile data, email address and mobile." + }, + { + "name": "Enrich Company", + "description": "Enrich a company with complete B2B data." + }, + { + "name": "Bulk Enrich Person", + "description": "Enrich up to 50 person records at once." + }, + { + "name": "Bulk Enrich Company", + "description": "Enrich up to 50 company records at once." + }, + { + "name": "Search Person", + "description": "Search for leads using 20+ filters to build targeted contact lists." + }, + { + "name": "Search Company", + "description": "Search for companies using 20+ filters to build account lists." + }, + { + "name": "Search Suggestions", + "description": "Free endpoint to retrieve valid location or job title values for use in Search filters. Provide exactly one of location_search or job_title_search." + }, + { + "name": "Account Information", + "description": "Retrieve the current plan, remaining credits, and renewal date of your Prospeo account." + } + ], + "operationCount": 8, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationTypes": ["sales"], + "tags": ["enrichment", "sales-engagement"] + }, { "type": "pulse_v2", "slug": "pulse", diff --git a/apps/sim/blocks/blocks/prospeo.ts b/apps/sim/blocks/blocks/prospeo.ts new file mode 100644 index 00000000000..823b1f04f24 --- /dev/null +++ b/apps/sim/blocks/blocks/prospeo.ts @@ -0,0 +1,441 @@ +import { ProspeoIcon } from '@/components/icons' +import { AuthMode, type BlockConfig, IntegrationType } from '@/blocks/types' +import type { ProspeoResponse } from '@/tools/prospeo/types' + +export const ProspeoBlock: BlockConfig = { + type: 'prospeo', + name: 'Prospeo', + description: 'Enrich and search B2B contacts and companies', + authMode: AuthMode.ApiKey, + longDescription: + 'Find verified work emails and mobile numbers, enrich person and company profiles, and search a B2B database of leads and companies using 20+ filters.', + docsLink: 'https://docs.sim.ai/tools/prospeo', + category: 'tools', + integrationType: IntegrationType.Sales, + tags: ['enrichment', 'sales-engagement'], + bgColor: '#FF1A26', + icon: ProspeoIcon, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Enrich Person', id: 'prospeo_enrich_person' }, + { label: 'Enrich Company', id: 'prospeo_enrich_company' }, + { label: 'Bulk Enrich Person', id: 'prospeo_bulk_enrich_person' }, + { label: 'Bulk Enrich Company', id: 'prospeo_bulk_enrich_company' }, + { label: 'Search Person', id: 'prospeo_search_person' }, + { label: 'Search Company', id: 'prospeo_search_company' }, + { label: 'Search Suggestions', id: 'prospeo_search_suggestions' }, + { label: 'Account Information', id: 'prospeo_account_information' }, + ], + value: () => 'prospeo_enrich_person', + }, + + // Enrich Person + { + id: 'first_name', + title: 'First Name', + type: 'short-input', + placeholder: 'e.g., Eva', + condition: { field: 'operation', value: 'prospeo_enrich_person' }, + }, + { + id: 'last_name', + title: 'Last Name', + type: 'short-input', + placeholder: 'e.g., Kiegler', + condition: { field: 'operation', value: 'prospeo_enrich_person' }, + }, + { + id: 'full_name', + title: 'Full Name', + type: 'short-input', + placeholder: 'e.g., Eva Kiegler (alternative to first/last name)', + condition: { field: 'operation', value: 'prospeo_enrich_person' }, + }, + { + id: 'linkedin_url', + title: 'LinkedIn URL', + type: 'short-input', + placeholder: 'https://www.linkedin.com/in/eva-kiegler', + condition: { field: 'operation', value: 'prospeo_enrich_person' }, + }, + { + id: 'email', + title: 'Email', + type: 'short-input', + placeholder: 'eva@intercom.com', + condition: { field: 'operation', value: 'prospeo_enrich_person' }, + }, + { + id: 'company_name', + title: 'Company Name', + type: 'short-input', + placeholder: 'Intercom', + condition: { field: 'operation', value: 'prospeo_enrich_person' }, + }, + { + id: 'company_website', + title: 'Company Website', + type: 'short-input', + placeholder: 'intercom.com', + condition: { field: 'operation', value: 'prospeo_enrich_person' }, + }, + { + id: 'company_linkedin_url', + title: 'Company LinkedIn URL', + type: 'short-input', + placeholder: 'https://www.linkedin.com/company/intercom', + condition: { field: 'operation', value: 'prospeo_enrich_person' }, + mode: 'advanced', + }, + { + id: 'person_id', + title: 'Person ID', + type: 'short-input', + placeholder: 'Prospeo person_id from a previous Search Person', + condition: { field: 'operation', value: 'prospeo_enrich_person' }, + mode: 'advanced', + }, + { + id: 'only_verified_email', + title: 'Only Verified Email', + type: 'switch', + condition: { field: 'operation', value: 'prospeo_enrich_person' }, + mode: 'advanced', + }, + { + id: 'enrich_mobile', + title: 'Enrich Mobile', + type: 'switch', + condition: { field: 'operation', value: 'prospeo_enrich_person' }, + mode: 'advanced', + }, + { + id: 'only_verified_mobile', + title: 'Only Verified Mobile', + type: 'switch', + condition: { field: 'operation', value: 'prospeo_enrich_person' }, + mode: 'advanced', + }, + + // Enrich Company + { + id: 'company_website', + title: 'Company Website', + type: 'short-input', + placeholder: 'intercom.com', + condition: { field: 'operation', value: 'prospeo_enrich_company' }, + }, + { + id: 'company_linkedin_url', + title: 'Company LinkedIn URL', + type: 'short-input', + placeholder: 'https://www.linkedin.com/company/intercom', + condition: { field: 'operation', value: 'prospeo_enrich_company' }, + }, + { + id: 'company_name', + title: 'Company Name', + type: 'short-input', + placeholder: 'Intercom', + condition: { field: 'operation', value: 'prospeo_enrich_company' }, + mode: 'advanced', + }, + { + id: 'company_id', + title: 'Company ID', + type: 'short-input', + placeholder: 'Prospeo company_id from a previous enrichment', + condition: { field: 'operation', value: 'prospeo_enrich_company' }, + mode: 'advanced', + }, + + // Bulk Enrich Person + { + id: 'data', + title: 'Records', + type: 'code', + language: 'json', + required: { field: 'operation', value: 'prospeo_bulk_enrich_person' }, + placeholder: + '[{"identifier":"1","linkedin_url":"https://www.linkedin.com/in/eva-kiegler"},{"identifier":"2","full_name":"Jane Doe","company_website":"acme.com"}]', + condition: { field: 'operation', value: 'prospeo_bulk_enrich_person' }, + wandConfig: { + enabled: true, + prompt: + 'Build a JSON array of up to 50 person records to enrich via Prospeo. Each item must include an "identifier" plus one valid match key set: linkedin_url, email, person_id, or (first_name + last_name + company_*), or (full_name + company_*). Return ONLY the JSON array.', + generationType: 'json-object', + }, + }, + { + id: 'only_verified_email', + title: 'Only Verified Email', + type: 'switch', + condition: { field: 'operation', value: 'prospeo_bulk_enrich_person' }, + mode: 'advanced', + }, + { + id: 'enrich_mobile', + title: 'Enrich Mobile', + type: 'switch', + condition: { field: 'operation', value: 'prospeo_bulk_enrich_person' }, + mode: 'advanced', + }, + { + id: 'only_verified_mobile', + title: 'Only Verified Mobile', + type: 'switch', + condition: { field: 'operation', value: 'prospeo_bulk_enrich_person' }, + mode: 'advanced', + }, + + // Bulk Enrich Company + { + id: 'data', + title: 'Records', + type: 'code', + language: 'json', + required: { field: 'operation', value: 'prospeo_bulk_enrich_company' }, + placeholder: + '[{"identifier":"1","company_website":"intercom.com"},{"identifier":"2","company_linkedin_url":"https://www.linkedin.com/company/deloitte"}]', + condition: { field: 'operation', value: 'prospeo_bulk_enrich_company' }, + wandConfig: { + enabled: true, + prompt: + 'Build a JSON array of up to 50 company records to enrich via Prospeo. Each item must include an "identifier" plus one of: company_website, company_linkedin_url, company_name, or company_id. Return ONLY the JSON array.', + generationType: 'json-object', + }, + }, + + // Search Person + { + id: 'filters', + title: 'Filters', + type: 'code', + language: 'json', + required: { field: 'operation', value: 'prospeo_search_person' }, + placeholder: + '{"person_seniority":{"include":["Founder/Owner"]},"company_industry":{"exclude":["Semiconductors"]}}', + condition: { field: 'operation', value: 'prospeo_search_person' }, + wandConfig: { + enabled: true, + prompt: + 'Build a Prospeo Search Person filters JSON object based on the user description. Use the documented filters (person_seniority, person_departments, person_year_of_experience, person_location, person_job_title, company_industry, company_headcount_range, company_funding, company_technology, etc.) with include/exclude or min/max keys as appropriate. Do not use only exclude filters. Return ONLY the JSON object for the filters value.', + generationType: 'json-object', + }, + }, + { + id: 'page', + title: 'Page', + type: 'short-input', + placeholder: '1', + condition: { field: 'operation', value: 'prospeo_search_person' }, + mode: 'advanced', + }, + + // Search Company + { + id: 'filters', + title: 'Filters', + type: 'code', + language: 'json', + required: { field: 'operation', value: 'prospeo_search_company' }, + placeholder: + '{"company_funding":{"stage":["Series B","Series C"]},"company_industry":{"exclude":["Semiconductors"]}}', + condition: { field: 'operation', value: 'prospeo_search_company' }, + wandConfig: { + enabled: true, + prompt: + 'Build a Prospeo Search Company filters JSON object based on the user description. Use the documented filters (company_industry, company_headcount_range, company_funding, company_technology, company_email_provider, company_naics, company_sics, company_location, etc.) with include/exclude or min/max keys as appropriate. Do not use only exclude filters. Return ONLY the JSON object for the filters value.', + generationType: 'json-object', + }, + }, + { + id: 'page', + title: 'Page', + type: 'short-input', + placeholder: '1', + condition: { field: 'operation', value: 'prospeo_search_company' }, + mode: 'advanced', + }, + + // Search Suggestions + { + id: 'location_search', + title: 'Location Query', + type: 'short-input', + placeholder: 'e.g., united states (min 2 characters)', + condition: { field: 'operation', value: 'prospeo_search_suggestions' }, + }, + { + id: 'job_title_search', + title: 'Job Title Query', + type: 'short-input', + placeholder: 'e.g., software engineer (min 2 characters)', + condition: { field: 'operation', value: 'prospeo_search_suggestions' }, + }, + + // API Key (always last) + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + required: true, + placeholder: 'Enter your Prospeo API key', + password: true, + }, + ], + tools: { + access: [ + 'prospeo_account_information', + 'prospeo_enrich_person', + 'prospeo_enrich_company', + 'prospeo_bulk_enrich_person', + 'prospeo_bulk_enrich_company', + 'prospeo_search_person', + 'prospeo_search_company', + 'prospeo_search_suggestions', + ], + config: { + tool: (params) => { + switch (params.operation) { + case 'prospeo_account_information': + return 'prospeo_account_information' + case 'prospeo_enrich_person': + return 'prospeo_enrich_person' + case 'prospeo_enrich_company': + return 'prospeo_enrich_company' + case 'prospeo_bulk_enrich_person': + return 'prospeo_bulk_enrich_person' + case 'prospeo_bulk_enrich_company': + return 'prospeo_bulk_enrich_company' + case 'prospeo_search_person': + return 'prospeo_search_person' + case 'prospeo_search_company': + return 'prospeo_search_company' + case 'prospeo_search_suggestions': + return 'prospeo_search_suggestions' + default: + return 'prospeo_enrich_person' + } + }, + params: (params) => { + const result: Record = {} + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === '') continue + if (key === 'page') { + const n = Number(value) + if (Number.isFinite(n)) result[key] = n + continue + } + if ( + key === 'only_verified_email' || + key === 'enrich_mobile' || + key === 'only_verified_mobile' + ) { + result[key] = typeof value === 'string' ? value === 'true' : Boolean(value) + continue + } + result[key] = value + } + return result + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + apiKey: { type: 'string', description: 'Prospeo API key' }, + // Enrich Person / Company match keys + first_name: { type: 'string', description: 'First name' }, + last_name: { type: 'string', description: 'Last name' }, + full_name: { type: 'string', description: 'Full name' }, + linkedin_url: { type: 'string', description: 'Person LinkedIn URL' }, + email: { type: 'string', description: 'Work email' }, + company_name: { type: 'string', description: 'Company name' }, + company_website: { type: 'string', description: 'Company website' }, + company_linkedin_url: { type: 'string', description: 'Company LinkedIn URL' }, + company_id: { type: 'string', description: 'Prospeo company_id' }, + person_id: { type: 'string', description: 'Prospeo person_id' }, + only_verified_email: { type: 'boolean', description: 'Only verified emails' }, + enrich_mobile: { type: 'boolean', description: 'Reveal mobile numbers' }, + only_verified_mobile: { type: 'boolean', description: 'Only records with a mobile' }, + // Bulk + data: { type: 'json', description: 'Array of records to enrich' }, + // Search + filters: { type: 'json', description: 'Search filters configuration' }, + page: { type: 'number', description: 'Page number (defaults to 1)' }, + // Suggestions + location_search: { type: 'string', description: 'Location search query' }, + job_title_search: { type: 'string', description: 'Job title search query' }, + }, + outputs: { + // Account information + current_plan: { type: 'string', description: 'Current plan name' }, + current_team_members: { + type: 'number', + description: 'Number of team members', + }, + remaining_credits: { type: 'number', description: 'Credits remaining' }, + used_credits: { type: 'number', description: 'Credits already used' }, + next_quota_renewal_days: { + type: 'number', + description: 'Days until the next quota renewal', + }, + next_quota_renewal_date: { + type: 'string', + description: 'Date of the next quota renewal', + }, + // Enrichment + free_enrichment: { + type: 'boolean', + description: 'True if this enrichment was free', + }, + person: { + type: 'json', + description: 'Enriched person object (enrich_person)', + }, + company: { + type: 'json', + description: 'Enriched / current company object (enrich_person, enrich_company)', + }, + // Bulk enrichment + total_cost: { type: 'number', description: 'Total credits spent (bulk)' }, + matched: { + type: 'array', + description: 'Matched records (bulk enrich)', + }, + not_matched: { + type: 'array', + description: 'Identifiers that did not match (bulk enrich)', + }, + invalid_datapoints: { + type: 'array', + description: 'Identifiers that failed minimum match requirements (bulk enrich)', + }, + // Search + free: { + type: 'boolean', + description: 'True if the search was free due to 30-day deduplication', + }, + results: { + type: 'array', + description: 'Search results (search_person, search_company)', + }, + pagination: { + type: 'json', + description: 'Pagination details (current_page, per_page, total_page, total_count)', + }, + // Suggestions + location_suggestions: { + type: 'array', + description: 'Location suggestions (search_suggestions)', + }, + job_title_suggestions: { + type: 'array', + description: 'Job title suggestions (search_suggestions)', + }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index cd6144ff5f7..2038e1f2792 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -156,6 +156,7 @@ import { PolymarketBlock } from '@/blocks/blocks/polymarket' import { PostgreSQLBlock } from '@/blocks/blocks/postgresql' import { PostHogBlock } from '@/blocks/blocks/posthog' import { ProfoundBlock } from '@/blocks/blocks/profound' +import { ProspeoBlock } from '@/blocks/blocks/prospeo' import { PulseBlock, PulseV2Block } from '@/blocks/blocks/pulse' import { QdrantBlock } from '@/blocks/blocks/qdrant' import { QuiverBlock } from '@/blocks/blocks/quiver' @@ -406,6 +407,7 @@ export const registry: Record = { pinecone: PineconeBlock, pipedrive: PipedriveBlock, profound: ProfoundBlock, + prospeo: ProspeoBlock, polymarket: PolymarketBlock, postgresql: PostgreSQLBlock, posthog: PostHogBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 95502bf3ff6..6f07869ffb3 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -1455,6 +1455,41 @@ export function ProfoundIcon(props: SVGProps) { ) } +export function ProspeoIcon(props: SVGProps) { + return ( + + + + + + + + + ) +} + export function PineconeIcon(props: SVGProps) { return ( = { + id: 'prospeo_account_information', + name: 'Prospeo Account Information', + description: + 'Retrieve the current plan, remaining credits, and renewal date of your Prospeo account.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Prospeo API key', + }, + }, + + request: { + url: 'https://api.prospeo.io/account-information', + method: 'GET', + headers: (params) => ({ + 'X-KEY': params.apiKey, + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await extractProspeoError(response)) + } + const data = await response.json() + const r = data.response ?? {} + return { + success: true, + output: { + current_plan: r.current_plan ?? null, + current_team_members: r.current_team_members ?? null, + remaining_credits: r.remaining_credits ?? null, + used_credits: r.used_credits ?? null, + next_quota_renewal_days: r.next_quota_renewal_days ?? null, + next_quota_renewal_date: r.next_quota_renewal_date ?? null, + }, + } + }, + + outputs: { + current_plan: { type: 'string', description: 'Current Prospeo plan name', optional: true }, + current_team_members: { + type: 'number', + description: 'Number of team members in your team', + optional: true, + }, + remaining_credits: { + type: 'number', + description: 'Number of credits remaining', + optional: true, + }, + used_credits: { type: 'number', description: 'Number of credits already used', optional: true }, + next_quota_renewal_days: { + type: 'number', + description: 'Days until the next quota renewal', + optional: true, + }, + next_quota_renewal_date: { + type: 'string', + description: 'Date and time of the next quota renewal', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/prospeo/bulk_enrich_company.ts b/apps/sim/tools/prospeo/bulk_enrich_company.ts new file mode 100644 index 00000000000..c73c6e70f12 --- /dev/null +++ b/apps/sim/tools/prospeo/bulk_enrich_company.ts @@ -0,0 +1,87 @@ +import { + extractProspeoError, + type ProspeoBulkEnrichCompanyParams, + type ProspeoBulkEnrichCompanyResponse, +} from '@/tools/prospeo/types' +import { parseDataArray } from '@/tools/prospeo/utils' +import type { ToolConfig } from '@/tools/types' + +export const bulkEnrichCompanyTool: ToolConfig< + ProspeoBulkEnrichCompanyParams, + ProspeoBulkEnrichCompanyResponse +> = { + id: 'prospeo_bulk_enrich_company', + name: 'Prospeo Bulk Enrich Company', + description: 'Enrich up to 50 company records at once.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Prospeo API key', + }, + data: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Array of up to 50 company records to enrich. Each must include an "identifier" plus one of: company_website, company_linkedin_url, company_name, or company_id.', + }, + }, + + request: { + url: 'https://api.prospeo.io/bulk-enrich-company', + method: 'POST', + headers: (params) => ({ + 'X-KEY': params.apiKey, + 'Content-Type': 'application/json', + }), + body: (params) => ({ data: parseDataArray(params.data) }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await extractProspeoError(response)) + } + const data = await response.json() + return { + success: true, + output: { + total_cost: data.total_cost ?? 0, + matched: data.matched ?? [], + not_matched: data.not_matched ?? [], + invalid_datapoints: data.invalid_datapoints ?? [], + }, + } + }, + + outputs: { + total_cost: { type: 'number', description: 'Total credits spent by the request' }, + matched: { + type: 'array', + description: 'Matched company records (identifier, company)', + items: { + type: 'object', + properties: { + identifier: { + type: 'string', + description: 'The identifier you submitted for this record', + }, + company: { type: 'json', description: 'The matched company object', optional: true }, + }, + }, + }, + not_matched: { + type: 'array', + description: 'Identifiers of records we could not match', + items: { type: 'string' }, + }, + invalid_datapoints: { + type: 'array', + description: 'Identifiers of records that did not meet the minimum matching requirements', + items: { type: 'string' }, + }, + }, +} diff --git a/apps/sim/tools/prospeo/bulk_enrich_person.ts b/apps/sim/tools/prospeo/bulk_enrich_person.ts new file mode 100644 index 00000000000..272b15a72b4 --- /dev/null +++ b/apps/sim/tools/prospeo/bulk_enrich_person.ts @@ -0,0 +1,118 @@ +import { + extractProspeoError, + type ProspeoBulkEnrichPersonParams, + type ProspeoBulkEnrichPersonResponse, +} from '@/tools/prospeo/types' +import { parseDataArray } from '@/tools/prospeo/utils' +import type { ToolConfig } from '@/tools/types' + +export const bulkEnrichPersonTool: ToolConfig< + ProspeoBulkEnrichPersonParams, + ProspeoBulkEnrichPersonResponse +> = { + id: 'prospeo_bulk_enrich_person', + name: 'Prospeo Bulk Enrich Person', + description: 'Enrich up to 50 person records at once.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Prospeo API key', + }, + data: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Array of up to 50 person records to enrich. Each must include an "identifier" plus one of: linkedin_url, email, person_id, or (first_name + last_name + company_*), or (full_name + company_*).', + }, + only_verified_email: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Only return records with a verified email', + }, + enrich_mobile: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Reveal mobile numbers (10 credits per match; email included)', + }, + only_verified_mobile: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Only return records that have a mobile (implies enrich_mobile)', + }, + }, + + request: { + url: 'https://api.prospeo.io/bulk-enrich-person', + method: 'POST', + headers: (params) => ({ + 'X-KEY': params.apiKey, + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { data: parseDataArray(params.data) } + if (params.only_verified_email !== undefined) + body.only_verified_email = params.only_verified_email + if (params.enrich_mobile !== undefined) body.enrich_mobile = params.enrich_mobile + if (params.only_verified_mobile !== undefined) + body.only_verified_mobile = params.only_verified_mobile + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await extractProspeoError(response)) + } + const data = await response.json() + return { + success: true, + output: { + total_cost: data.total_cost ?? 0, + matched: data.matched ?? [], + not_matched: data.not_matched ?? [], + invalid_datapoints: data.invalid_datapoints ?? [], + }, + } + }, + + outputs: { + total_cost: { type: 'number', description: 'Total credits spent by the request' }, + matched: { + type: 'array', + description: 'Matched records (identifier, person, company)', + items: { + type: 'object', + properties: { + identifier: { + type: 'string', + description: 'The identifier you submitted for this record', + }, + person: { type: 'json', description: 'The matched person object', optional: true }, + company: { + type: 'json', + description: 'The current company of the matched person', + optional: true, + }, + }, + }, + }, + not_matched: { + type: 'array', + description: 'Identifiers of records we could not match given the filters', + items: { type: 'string' }, + }, + invalid_datapoints: { + type: 'array', + description: 'Identifiers of records that did not meet the minimum matching requirements', + items: { type: 'string' }, + }, + }, +} diff --git a/apps/sim/tools/prospeo/enrich_company.ts b/apps/sim/tools/prospeo/enrich_company.ts new file mode 100644 index 00000000000..ca4e1ed9b99 --- /dev/null +++ b/apps/sim/tools/prospeo/enrich_company.ts @@ -0,0 +1,93 @@ +import { + extractProspeoError, + type ProspeoEnrichCompanyParams, + type ProspeoEnrichCompanyResponse, +} from '@/tools/prospeo/types' +import type { ToolConfig } from '@/tools/types' + +export const enrichCompanyTool: ToolConfig< + ProspeoEnrichCompanyParams, + ProspeoEnrichCompanyResponse +> = { + id: 'prospeo_enrich_company', + name: 'Prospeo Enrich Company', + description: 'Enrich a company with complete B2B data.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Prospeo API key', + }, + company_website: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company website (e.g., "intercom.com")', + }, + company_linkedin_url: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: "Company's public LinkedIn URL", + }, + company_name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company name (use combined with website for best accuracy)', + }, + company_id: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Prospeo company_id from a previously enriched company', + }, + }, + + request: { + url: 'https://api.prospeo.io/enrich-company', + method: 'POST', + headers: (params) => ({ + 'X-KEY': params.apiKey, + 'Content-Type': 'application/json', + }), + body: (params) => { + const data: Record = {} + if (params.company_website) data.company_website = params.company_website + if (params.company_linkedin_url) data.company_linkedin_url = params.company_linkedin_url + if (params.company_name) data.company_name = params.company_name + if (params.company_id) data.company_id = params.company_id + return { data } + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await extractProspeoError(response)) + } + const data = await response.json() + return { + success: true, + output: { + free_enrichment: data.free_enrichment ?? false, + company: data.company ?? null, + }, + } + }, + + outputs: { + free_enrichment: { + type: 'boolean', + description: 'True if this enrichment was free (already enriched in the past)', + }, + company: { + type: 'json', + description: + 'The matched company object including name, website, domain, industry, employee_count, location, social URLs, funding, and technology', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/prospeo/enrich_person.ts b/apps/sim/tools/prospeo/enrich_person.ts new file mode 100644 index 00000000000..2187383e059 --- /dev/null +++ b/apps/sim/tools/prospeo/enrich_person.ts @@ -0,0 +1,158 @@ +import { + extractProspeoError, + type ProspeoEnrichPersonParams, + type ProspeoEnrichPersonResponse, +} from '@/tools/prospeo/types' +import type { ToolConfig } from '@/tools/types' + +export const enrichPersonTool: ToolConfig = + { + id: 'prospeo_enrich_person', + name: 'Prospeo Enrich Person', + description: 'Enrich a person with complete B2B profile data, email address and mobile.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Prospeo API key', + }, + first_name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'First name of the person', + }, + last_name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Last name of the person', + }, + full_name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Full name of the person (alternative to first_name + last_name)', + }, + linkedin_url: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: "Person's public LinkedIn URL", + }, + email: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Work email of the person', + }, + company_name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company name', + }, + company_website: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company website', + }, + company_linkedin_url: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: "Company's public LinkedIn URL", + }, + person_id: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Prospeo person_id from a previous Search Person response', + }, + only_verified_email: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Only return records with a verified email', + }, + enrich_mobile: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Reveal mobile number (10 credits per match; email included)', + }, + only_verified_mobile: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Only return records that have a mobile (implies enrich_mobile)', + }, + }, + + request: { + url: 'https://api.prospeo.io/enrich-person', + method: 'POST', + headers: (params) => ({ + 'X-KEY': params.apiKey, + 'Content-Type': 'application/json', + }), + body: (params) => { + const data: Record = {} + if (params.first_name) data.first_name = params.first_name + if (params.last_name) data.last_name = params.last_name + if (params.full_name) data.full_name = params.full_name + if (params.linkedin_url) data.linkedin_url = params.linkedin_url + if (params.email) data.email = params.email + if (params.company_name) data.company_name = params.company_name + if (params.company_website) data.company_website = params.company_website + if (params.company_linkedin_url) data.company_linkedin_url = params.company_linkedin_url + if (params.person_id) data.person_id = params.person_id + + const body: Record = { data } + if (params.only_verified_email !== undefined) + body.only_verified_email = params.only_verified_email + if (params.enrich_mobile !== undefined) body.enrich_mobile = params.enrich_mobile + if (params.only_verified_mobile !== undefined) + body.only_verified_mobile = params.only_verified_mobile + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await extractProspeoError(response)) + } + const data = await response.json() + return { + success: true, + output: { + free_enrichment: data.free_enrichment ?? false, + person: data.person ?? null, + company: data.company ?? null, + }, + } + }, + + outputs: { + free_enrichment: { + type: 'boolean', + description: 'True if this enrichment was free (already enriched in the past)', + }, + person: { + type: 'json', + description: + 'The matched person object including person_id, name, linkedin_url, current_job_title, job_history, mobile, email, location, and skills', + optional: true, + }, + company: { + type: 'json', + description: + 'The current company of the matched person including name, website, domain, industry, employee_count, location, social URLs, funding, and technology', + optional: true, + }, + }, + } diff --git a/apps/sim/tools/prospeo/index.ts b/apps/sim/tools/prospeo/index.ts new file mode 100644 index 00000000000..13967ae2c85 --- /dev/null +++ b/apps/sim/tools/prospeo/index.ts @@ -0,0 +1,17 @@ +import { accountInformationTool } from '@/tools/prospeo/account_information' +import { bulkEnrichCompanyTool } from '@/tools/prospeo/bulk_enrich_company' +import { bulkEnrichPersonTool } from '@/tools/prospeo/bulk_enrich_person' +import { enrichCompanyTool } from '@/tools/prospeo/enrich_company' +import { enrichPersonTool } from '@/tools/prospeo/enrich_person' +import { searchCompanyTool } from '@/tools/prospeo/search_company' +import { searchPersonTool } from '@/tools/prospeo/search_person' +import { searchSuggestionsTool } from '@/tools/prospeo/search_suggestions' + +export const prospeoAccountInformationTool = accountInformationTool +export const prospeoEnrichPersonTool = enrichPersonTool +export const prospeoEnrichCompanyTool = enrichCompanyTool +export const prospeoBulkEnrichPersonTool = bulkEnrichPersonTool +export const prospeoBulkEnrichCompanyTool = bulkEnrichCompanyTool +export const prospeoSearchPersonTool = searchPersonTool +export const prospeoSearchCompanyTool = searchCompanyTool +export const prospeoSearchSuggestionsTool = searchSuggestionsTool diff --git a/apps/sim/tools/prospeo/search_company.ts b/apps/sim/tools/prospeo/search_company.ts new file mode 100644 index 00000000000..f4e8a1c0b52 --- /dev/null +++ b/apps/sim/tools/prospeo/search_company.ts @@ -0,0 +1,87 @@ +import { + extractProspeoError, + PROSPEO_PAGINATION_OUTPUT, + type ProspeoSearchCompanyParams, + type ProspeoSearchCompanyResponse, +} from '@/tools/prospeo/types' +import { parseFiltersObject } from '@/tools/prospeo/utils' +import type { ToolConfig } from '@/tools/types' + +export const searchCompanyTool: ToolConfig< + ProspeoSearchCompanyParams, + ProspeoSearchCompanyResponse +> = { + id: 'prospeo_search_company', + name: 'Prospeo Search Company', + description: 'Search for companies using 20+ filters to build account lists.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Prospeo API key', + }, + filters: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Filter configuration object. See https://prospeo.io/api-docs/filters-documentation for all supported filters (e.g., company_industry, company_headcount_range, company_funding).', + }, + page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Page number (defaults to 1). Up to 1000 pages of 25 results.', + }, + }, + + request: { + url: 'https://api.prospeo.io/search-company', + method: 'POST', + headers: (params) => ({ + 'X-KEY': params.apiKey, + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { filters: parseFiltersObject(params.filters) } + if (params.page !== undefined) body.page = params.page + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await extractProspeoError(response)) + } + const data = await response.json() + return { + success: true, + output: { + free: data.free ?? false, + results: data.results ?? [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + free: { + type: 'boolean', + description: 'True if the request was free due to 30-day result-set deduplication', + }, + results: { + type: 'array', + description: 'Up to 25 matching companies', + items: { + type: 'object', + properties: { + company: { type: 'json', description: 'Matched company object' }, + }, + }, + }, + pagination: PROSPEO_PAGINATION_OUTPUT, + }, +} diff --git a/apps/sim/tools/prospeo/search_person.ts b/apps/sim/tools/prospeo/search_person.ts new file mode 100644 index 00000000000..c8415ceec9e --- /dev/null +++ b/apps/sim/tools/prospeo/search_person.ts @@ -0,0 +1,89 @@ +import { + extractProspeoError, + PROSPEO_PAGINATION_OUTPUT, + type ProspeoSearchPersonParams, + type ProspeoSearchPersonResponse, +} from '@/tools/prospeo/types' +import { parseFiltersObject } from '@/tools/prospeo/utils' +import type { ToolConfig } from '@/tools/types' + +export const searchPersonTool: ToolConfig = + { + id: 'prospeo_search_person', + name: 'Prospeo Search Person', + description: 'Search for leads using 20+ filters to build targeted contact lists.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Prospeo API key', + }, + filters: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Filter configuration object. See https://prospeo.io/api-docs/filters-documentation for all supported filters (e.g., person_seniority, company_industry, person_location).', + }, + page: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Page number (defaults to 1). Up to 1000 pages of 25 results.', + }, + }, + + request: { + url: 'https://api.prospeo.io/search-person', + method: 'POST', + headers: (params) => ({ + 'X-KEY': params.apiKey, + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { filters: parseFiltersObject(params.filters) } + if (params.page !== undefined) body.page = params.page + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await extractProspeoError(response)) + } + const data = await response.json() + return { + success: true, + output: { + free: data.free ?? false, + results: data.results ?? [], + pagination: data.pagination ?? null, + }, + } + }, + + outputs: { + free: { + type: 'boolean', + description: 'True if the request was free due to 30-day result-set deduplication', + }, + results: { + type: 'array', + description: 'Up to 25 search results (person + company, no email/mobile)', + items: { + type: 'object', + properties: { + person: { + type: 'json', + description: 'Matched person (no email/mobile in search response)', + }, + company: { type: 'json', description: 'Current company of the person', optional: true }, + }, + }, + }, + pagination: PROSPEO_PAGINATION_OUTPUT, + }, + } diff --git a/apps/sim/tools/prospeo/search_suggestions.ts b/apps/sim/tools/prospeo/search_suggestions.ts new file mode 100644 index 00000000000..22cd7c6ef63 --- /dev/null +++ b/apps/sim/tools/prospeo/search_suggestions.ts @@ -0,0 +1,95 @@ +import { + extractProspeoError, + type ProspeoSearchSuggestionsParams, + type ProspeoSearchSuggestionsResponse, +} from '@/tools/prospeo/types' +import type { ToolConfig } from '@/tools/types' + +export const searchSuggestionsTool: ToolConfig< + ProspeoSearchSuggestionsParams, + ProspeoSearchSuggestionsResponse +> = { + id: 'prospeo_search_suggestions', + name: 'Prospeo Search Suggestions', + description: + 'Free endpoint to retrieve valid location or job title values for use in Search filters. Provide exactly one of location_search or job_title_search.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Prospeo API key', + }, + location_search: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Search query for location suggestions (minimum 2 characters). Mutually exclusive with job_title_search.', + }, + job_title_search: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Search query for job title suggestions (minimum 2 characters). Mutually exclusive with location_search.', + }, + }, + + request: { + url: 'https://api.prospeo.io/search-suggestions', + method: 'POST', + headers: (params) => ({ + 'X-KEY': params.apiKey, + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = {} + if (params.location_search) body.location_search = params.location_search + if (params.job_title_search) body.job_title_search = params.job_title_search + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + throw new Error(await extractProspeoError(response)) + } + const data = await response.json() + return { + success: true, + output: { + location_suggestions: data.location_suggestions ?? null, + job_title_suggestions: data.job_title_suggestions ?? null, + }, + } + }, + + outputs: { + location_suggestions: { + type: 'array', + description: + 'Location suggestions when using location_search (null when searching job titles)', + optional: true, + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Formatted location name to use in filters' }, + type: { + type: 'string', + description: 'Location type (COUNTRY, STATE, CITY, or ZONE)', + }, + }, + }, + }, + job_title_suggestions: { + type: 'array', + description: + 'Up to 25 job title suggestions ordered by popularity when using job_title_search (null when searching locations)', + optional: true, + items: { type: 'string' }, + }, + }, +} diff --git a/apps/sim/tools/prospeo/types.ts b/apps/sim/tools/prospeo/types.ts new file mode 100644 index 00000000000..391006b4678 --- /dev/null +++ b/apps/sim/tools/prospeo/types.ts @@ -0,0 +1,193 @@ +import type { OutputProperty, ToolResponse } from '@/tools/types' + +export interface ProspeoBaseParams { + apiKey: string +} + +export interface ProspeoPersonData { + identifier?: string + first_name?: string + last_name?: string + full_name?: string + linkedin_url?: string + email?: string + company_name?: string + company_website?: string + company_linkedin_url?: string + person_id?: string +} + +export interface ProspeoCompanyData { + identifier?: string + company_name?: string + company_website?: string + company_linkedin_url?: string + company_id?: string +} + +export interface ProspeoPaginationOutput { + current_page: number + per_page: number + total_page: number + total_count: number +} + +export const PROSPEO_PAGINATION_OUTPUT: OutputProperty = { + type: 'object', + description: 'Pagination details', + properties: { + current_page: { type: 'number', description: 'Current page number' }, + per_page: { type: 'number', description: 'Results per page' }, + total_page: { type: 'number', description: 'Total number of pages' }, + total_count: { type: 'number', description: 'Total number of matching records' }, + }, +} + +/** Account Information */ +export interface ProspeoAccountInformationParams extends ProspeoBaseParams {} + +export interface ProspeoAccountInformationResponse extends ToolResponse { + output: { + current_plan: string | null + current_team_members: number | null + remaining_credits: number | null + used_credits: number | null + next_quota_renewal_days: number | null + next_quota_renewal_date: string | null + } +} + +/** Enrich Person */ +export interface ProspeoEnrichPersonParams extends ProspeoBaseParams, ProspeoPersonData { + only_verified_email?: boolean + enrich_mobile?: boolean + only_verified_mobile?: boolean +} + +export interface ProspeoEnrichPersonResponse extends ToolResponse { + output: { + free_enrichment: boolean + person: Record | null + company: Record | null + } +} + +/** Enrich Company */ +export interface ProspeoEnrichCompanyParams extends ProspeoBaseParams, ProspeoCompanyData {} + +export interface ProspeoEnrichCompanyResponse extends ToolResponse { + output: { + free_enrichment: boolean + company: Record | null + } +} + +/** Bulk Enrich Person */ +export interface ProspeoBulkEnrichPersonParams extends ProspeoBaseParams { + data: ProspeoPersonData[] + only_verified_email?: boolean + enrich_mobile?: boolean + only_verified_mobile?: boolean +} + +export interface ProspeoBulkEnrichPersonResponse extends ToolResponse { + output: { + total_cost: number + matched: Array<{ + identifier: string + person: Record | null + company: Record | null + }> + not_matched: string[] + invalid_datapoints: string[] + } +} + +/** Bulk Enrich Company */ +export interface ProspeoBulkEnrichCompanyParams extends ProspeoBaseParams { + data: ProspeoCompanyData[] +} + +export interface ProspeoBulkEnrichCompanyResponse extends ToolResponse { + output: { + total_cost: number + matched: Array<{ + identifier: string + company: Record | null + }> + not_matched: string[] + invalid_datapoints: string[] + } +} + +/** Search Person */ +export interface ProspeoSearchPersonParams extends ProspeoBaseParams { + filters: Record | string + page?: number +} + +export interface ProspeoSearchPersonResponse extends ToolResponse { + output: { + free: boolean + results: Array<{ + person: Record | null + company: Record | null + }> + pagination: ProspeoPaginationOutput | null + } +} + +/** Search Company */ +export interface ProspeoSearchCompanyParams extends ProspeoBaseParams { + filters: Record | string + page?: number +} + +export interface ProspeoSearchCompanyResponse extends ToolResponse { + output: { + free: boolean + results: Array<{ + company: Record | null + }> + pagination: ProspeoPaginationOutput | null + } +} + +/** Search Suggestions */ +export interface ProspeoSearchSuggestionsParams extends ProspeoBaseParams { + location_search?: string + job_title_search?: string +} + +export interface ProspeoSearchSuggestionsResponse extends ToolResponse { + output: { + location_suggestions: Array<{ name: string; type: string }> | null + job_title_suggestions: string[] | null + } +} + +export type ProspeoResponse = + | ProspeoAccountInformationResponse + | ProspeoEnrichPersonResponse + | ProspeoEnrichCompanyResponse + | ProspeoBulkEnrichPersonResponse + | ProspeoBulkEnrichCompanyResponse + | ProspeoSearchPersonResponse + | ProspeoSearchCompanyResponse + | ProspeoSearchSuggestionsResponse + +/** + * Build a Prospeo API error message from a non-OK response payload. + */ +export async function extractProspeoError(response: Response): Promise { + try { + const data = (await response.json()) as { + error_code?: string + filter_error?: string + message?: string + } + const parts = [data.error_code, data.filter_error, data.message].filter(Boolean) + if (parts.length > 0) return parts.join(': ') + } catch {} + return `Prospeo API error: ${response.status}` +} diff --git a/apps/sim/tools/prospeo/utils.ts b/apps/sim/tools/prospeo/utils.ts new file mode 100644 index 00000000000..5fc0934f7d8 --- /dev/null +++ b/apps/sim/tools/prospeo/utils.ts @@ -0,0 +1,27 @@ +export function parseDataArray(value: unknown): unknown[] { + if (Array.isArray(value)) return value + if (typeof value === 'string' && value.trim().length > 0) { + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } + } + return [] +} + +export function parseFiltersObject(value: unknown): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record + } + if (typeof value === 'string' && value.trim().length > 0) { + try { + const parsed = JSON.parse(value) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record + } + } catch {} + } + return {} +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 52e6db1b6bc..2f8b48ff3c2 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -2049,6 +2049,16 @@ import { profoundSentimentReportTool, profoundVisibilityReportTool, } from '@/tools/profound' +import { + prospeoAccountInformationTool, + prospeoBulkEnrichCompanyTool, + prospeoBulkEnrichPersonTool, + prospeoEnrichCompanyTool, + prospeoEnrichPersonTool, + prospeoSearchCompanyTool, + prospeoSearchPersonTool, + prospeoSearchSuggestionsTool, +} from '@/tools/prospeo' import { pulseParserTool, pulseParserV2Tool } from '@/tools/pulse' import { qdrantFetchTool, qdrantSearchTool, qdrantUpsertTool } from '@/tools/qdrant' import { quiverImageToSvgTool, quiverListModelsTool, quiverTextToSvgTool } from '@/tools/quiver' @@ -5273,6 +5283,14 @@ export const tools: Record = { hunter_email_verifier: hunterEmailVerifierTool, hunter_companies_find: hunterCompaniesFindTool, hunter_email_count: hunterEmailCountTool, + prospeo_account_information: prospeoAccountInformationTool, + prospeo_enrich_person: prospeoEnrichPersonTool, + prospeo_enrich_company: prospeoEnrichCompanyTool, + prospeo_bulk_enrich_person: prospeoBulkEnrichPersonTool, + prospeo_bulk_enrich_company: prospeoBulkEnrichCompanyTool, + prospeo_search_person: prospeoSearchPersonTool, + prospeo_search_company: prospeoSearchCompanyTool, + prospeo_search_suggestions: prospeoSearchSuggestionsTool, iam_list_users: iamListUsersTool, iam_get_user: iamGetUserTool, iam_create_user: iamCreateUserTool, From f3cf8fc55dfb90f00ecba958fe9470a2fa1d0f7b Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 18 May 2026 12:53:32 -0700 Subject: [PATCH 07/10] feat(findymail): add Findymail B2B contact data integration (#4654) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(findymail): add Findymail B2B contact data integration Adds 11 tools covering verified email lookup (by name, LinkedIn, domain roles), email verification, reverse email lookup with profile enrichment, company info, employee discovery, phone lookup, technology stack detection, and credit checks. Single API-key block with operation dropdown, gradient-rendered icon, and generated docs. * fix(findymail): handle HTTP errors and surface last_detected_at - All 11 tools now check response.ok and return success:false with the API error message on non-2xx responses - search_technologies now maps last_detected_at to match lookup_technologies and the shared output schema - Restore file_v3 in docs icon-mapping (translated docs still reference it) * improvement(findymail): exclude operation from params transform Match the convention used by enrich/apify/box/calendly — destructure out operation before forwarding the rest to the tool call, so the operation key doesn't leak into the tool payload. --- apps/docs/components/icons.tsx | 43 +++ apps/docs/components/ui/icon-mapping.ts | 3 + apps/docs/content/docs/en/tools/findymail.mdx | 286 +++++++++++++++ apps/docs/content/docs/en/tools/meta.json | 1 + .../integrations/data/icon-mapping.ts | 2 + .../integrations/data/integrations.json | 63 ++++ apps/sim/blocks/blocks/findymail.ts | 345 ++++++++++++++++++ apps/sim/blocks/registry.ts | 2 + apps/sim/components/icons.tsx | 43 +++ .../findymail/find_email_from_linkedin.ts | 75 ++++ .../tools/findymail/find_email_from_name.ts | 80 ++++ .../tools/findymail/find_emails_by_domain.ts | 77 ++++ apps/sim/tools/findymail/find_employees.ts | 100 +++++ apps/sim/tools/findymail/find_phone.ts | 71 ++++ apps/sim/tools/findymail/get_company.ts | 102 ++++++ apps/sim/tools/findymail/get_credits.ts | 56 +++ apps/sim/tools/findymail/index.ts | 23 ++ .../tools/findymail/lookup_technologies.ts | 98 +++++ .../tools/findymail/reverse_email_lookup.ts | 149 ++++++++ .../tools/findymail/search_technologies.ts | 80 ++++ apps/sim/tools/findymail/types.ts | 241 ++++++++++++ apps/sim/tools/findymail/verify_email.ts | 71 ++++ apps/sim/tools/registry.ts | 24 ++ 23 files changed, 2035 insertions(+) create mode 100644 apps/docs/content/docs/en/tools/findymail.mdx create mode 100644 apps/sim/blocks/blocks/findymail.ts create mode 100644 apps/sim/tools/findymail/find_email_from_linkedin.ts create mode 100644 apps/sim/tools/findymail/find_email_from_name.ts create mode 100644 apps/sim/tools/findymail/find_emails_by_domain.ts create mode 100644 apps/sim/tools/findymail/find_employees.ts create mode 100644 apps/sim/tools/findymail/find_phone.ts create mode 100644 apps/sim/tools/findymail/get_company.ts create mode 100644 apps/sim/tools/findymail/get_credits.ts create mode 100644 apps/sim/tools/findymail/index.ts create mode 100644 apps/sim/tools/findymail/lookup_technologies.ts create mode 100644 apps/sim/tools/findymail/reverse_email_lookup.ts create mode 100644 apps/sim/tools/findymail/search_technologies.ts create mode 100644 apps/sim/tools/findymail/types.ts create mode 100644 apps/sim/tools/findymail/verify_email.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 6f07869ffb3..81dbe58d948 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -2283,6 +2283,49 @@ export function ElevenLabsIcon(props: SVGProps) { ) } +export function FindymailIcon(props: SVGProps) { + return ( + + + + + + + + + + + + + + + + ) +} export function FathomIcon(props: SVGProps) { return ( diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 0cb344dafc1..0de540e9e9f 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -58,6 +58,7 @@ import { ExtendIcon, EyeIcon, FathomIcon, + FindymailIcon, FirecrawlIcon, FirefliesIcon, GammaIcon, @@ -262,7 +263,9 @@ export const blockTypeToIconMap: Record = { extend_v2: ExtendIcon, fathom: FathomIcon, file: DocumentIcon, + file_v3: DocumentIcon, file_v4: DocumentIcon, + findymail: FindymailIcon, firecrawl: FirecrawlIcon, fireflies: FirefliesIcon, fireflies_v2: FirefliesIcon, diff --git a/apps/docs/content/docs/en/tools/findymail.mdx b/apps/docs/content/docs/en/tools/findymail.mdx new file mode 100644 index 00000000000..f0a226324d7 --- /dev/null +++ b/apps/docs/content/docs/en/tools/findymail.mdx @@ -0,0 +1,286 @@ +--- +title: Findymail +description: Find and verify B2B emails, phones, employees, and company data +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Findymail](https://findymail.com/) is a B2B contact data platform for finding and verifying work emails, phone numbers, and enriched profile data on company employees. It combines real-time email finding, deliverability verification, reverse-lookup, and technology stack detection in a single API. + +With Findymail, you can: + +- **Find work emails by name and company:** Resolve a verified work email from a person's name plus a company domain or company name. +- **Find emails from LinkedIn:** Look up the verified work email behind any LinkedIn profile URL. +- **Find contacts by role:** Search a company domain for verified emails matching specific target roles (e.g., CEO, Founder). +- **Verify deliverability:** Check whether an email is deliverable and identify the underlying mail provider. +- **Reverse-lookup profiles:** Given an email, return the matching LinkedIn URL and an optional enriched profile (job, education, skills, certificates). +- **Enrich companies and employees:** Look up company metadata by LinkedIn URL, domain, or name, and find employees by website and target job titles. +- **Find phone numbers:** Retrieve a contact's phone number (US-only) from a LinkedIn profile URL. +- **Detect technology stacks:** Search the technology catalog or look up the full tech stack of a company by domain. + +In Sim, the Findymail integration lets your agents programmatically build verified contact lists, enrich CRMs, qualify leads, and gather technographic data without leaving your workflow. Use it to automate outbound prospecting, augment incoming form submissions, validate email captures before sending, and trigger downstream actions when a verified contact is found. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate Findymail to find verified work emails by name, domain, or LinkedIn URL, verify deliverability, reverse-lookup profiles from emails, enrich company data, find employees by job title, look up phone numbers, search technology stacks, and check credit usage. + + + +## Tools + +### `findymail_verify_email` + +Verifies the deliverability of an email address. Uses one verifier credit. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `email` | string | Yes | Email address to verify \(e.g., john@example.com\) | +| `apiKey` | string | Yes | Findymail API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `email` | string | The verified email address | +| `verified` | boolean | Whether the email is verified as deliverable | +| `provider` | string | Email service provider \(e.g., Google, Microsoft\) | + +### `findymail_find_email_from_name` + +Find someone + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `name` | string | Yes | Person's full name \(e.g., 'John Doe'\) | +| `domain` | string | Yes | Company domain \(preferred\) or company name \(e.g., stripe.com\) | +| `apiKey` | string | Yes | Findymail API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `contact` | object | Contact information | +| ↳ `name` | string | Contact full name | +| ↳ `email` | string | Contact email address | +| ↳ `domain` | string | Email domain | + +### `findymail_find_emails_by_domain` + +Find verified contacts at a given domain matching one or more target roles (max 3 roles). Limited to 5 concurrent synchronous requests. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Company domain \(e.g., stripe.com\) | +| `roles` | array | Yes | Target roles at the company \(max 3, e.g., \["CEO", "Founder"\]\) | +| `items` | string | No | No description | +| `apiKey` | string | Yes | Findymail API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `contacts` | array | List of contacts found | +| ↳ `name` | string | Contact full name | +| ↳ `email` | string | Contact email address | +| ↳ `domain` | string | Email domain | + +### `findymail_find_email_from_linkedin` + +Find someone + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `linkedin_url` | string | Yes | Person's LinkedIn URL or username \(e.g., 'https://linkedin.com/in/johndoe' or 'johndoe'\) | +| `apiKey` | string | Yes | Findymail API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `contact` | object | Contact information | +| ↳ `name` | string | Contact full name | +| ↳ `email` | string | Contact email address | +| ↳ `domain` | string | Email domain | + +### `findymail_reverse_email_lookup` + +Find a business profile from an email address. Uses 1 finder credit if a profile is found, 2 credits if returning full profile data. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `email` | string | Yes | Work or personal email address to look up | +| `with_profile` | boolean | No | Whether to return enriched profile metadata \(default: false\) | +| `apiKey` | string | Yes | Findymail API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `email` | string | The email address that was looked up | +| `linkedin_url` | string | LinkedIn profile URL | +| `fullName` | string | Full name from profile | +| `username` | string | LinkedIn username | +| `headline` | string | Profile headline | +| `jobTitle` | string | Current job title | +| `summary` | string | Profile summary | +| `city` | string | City | +| `region` | string | Region or state | +| `country` | string | Country | +| `companyLinkedinUrl` | string | Current company LinkedIn URL | +| `companyName` | string | Current company name | +| `companyWebsite` | string | Current company website | +| `isPremium` | boolean | Whether the profile has LinkedIn Premium | +| `isOpenProfile` | boolean | Whether the profile is an Open Profile | +| `skills` | array | List of profile skills | +| `jobs` | array | Job history entries | +| `educations` | array | Education history \(school, degree, fieldOfStudy, startDate, endDate\) | +| `certificates` | array | Certifications \(name, issuingOrganization, issueDate, expirationDate\) | + +### `findymail_get_company` + +Retrieve company information from a LinkedIn URL, domain, or company name. Uses 1 finder credit per successful response. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `linkedin_url` | string | No | Company LinkedIn URL \(e.g., https://www.linkedin.com/company/stripe/\) | +| `domain` | string | No | Company domain \(e.g., stripe.com\) | +| `name` | string | No | Company name \(e.g., Stripe\) | +| `apiKey` | string | Yes | Findymail API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `name` | string | Company name | +| `domain` | string | Company domain | +| `company_size` | string | Employee headcount range \(e.g., 1001-5000\) | +| `industry` | string | Industry classification | +| `linkedin_url` | string | Company LinkedIn URL | +| `description` | string | Company description | + +### `findymail_find_employees` + +Find employees at a company by website and target job titles. Uses 1 credit per found contact. Does not return email addresses. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `website` | string | Yes | Company website or domain \(e.g., google.com\) | +| `job_titles` | array | Yes | Target job titles to search for \(max 10, e.g., \["Software Engineer", "CEO"\]\) | +| `items` | string | No | No description | +| `count` | number | No | Number of contacts to return \(max 5, default 1\) | +| `apiKey` | string | Yes | Findymail API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `employees` | array | List of employees matching the search criteria | +| ↳ `name` | string | Employee full name | +| ↳ `linkedinUrl` | string | LinkedIn profile URL | +| ↳ `companyWebsite` | string | Company website | +| ↳ `companyName` | string | Company name | +| ↳ `jobTitle` | string | Job title | + +### `findymail_find_phone` + +Find someone + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `linkedin_url` | string | Yes | Person's LinkedIn URL or username \(e.g., 'https://linkedin.com/in/johndoe' or 'johndoe'\) | +| `apiKey` | string | Yes | Findymail API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `phone` | string | Phone number in E.164 format. Only available for US numbers. | +| `line_type` | string | Phone line type \(e.g., "Mobile", "Landline"\) | + +### `findymail_search_technologies` + +Search the technology catalog by name. Returns up to 25 technologies. Free endpoint, rate limited to 10 requests per minute. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `q` | string | Yes | Search term \(min 2 characters, e.g., "React"\) | +| `apiKey` | string | Yes | Findymail API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `technologies` | array | List of technologies | +| ↳ `name` | string | Technology name | +| ↳ `category` | string | Technology category | +| ↳ `subcategory` | string | Technology subcategory | +| ↳ `last_detected_at` | string | Last detection timestamp \(ISO 8601\) | + +### `findymail_lookup_technologies` + +Get the technology stack for a company by domain. Optionally filter by technology names. 1 finder credit if technologies are found, free otherwise. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `domain` | string | Yes | Company domain to look up \(e.g., stripe.com\) | +| `technologies` | array | No | Filter by technology names, case-insensitive \(e.g., \["React", "TypeScript"\]\) | +| `items` | string | No | No description | +| `apiKey` | string | Yes | Findymail API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `technologies` | array | List of technologies | +| ↳ `name` | string | Technology name | +| ↳ `category` | string | Technology category | +| ↳ `subcategory` | string | Technology subcategory | +| ↳ `last_detected_at` | string | Last detection timestamp \(ISO 8601\) | +| `domain` | string | The resolved company domain | + +### `findymail_get_credits` + +Retrieve the remaining finder and verifier credits for the authenticated account. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Findymail API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `credits` | number | Remaining finder credits | +| `verifier_credits` | number | Remaining verifier credits | + + diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index e3fd30b75e9..5f04f102bb8 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -53,6 +53,7 @@ "extend", "fathom", "file", + "findymail", "firecrawl", "fireflies", "gamma", diff --git a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts index dbae80a2539..64fe8a8556d 100644 --- a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts +++ b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts @@ -58,6 +58,7 @@ import { ExtendIcon, EyeIcon, FathomIcon, + FindymailIcon, FirecrawlIcon, FirefliesIcon, GammaIcon, @@ -259,6 +260,7 @@ export const blockTypeToIconMap: Record = { extend_v2: ExtendIcon, fathom: FathomIcon, file_v4: DocumentIcon, + findymail: FindymailIcon, firecrawl: FirecrawlIcon, fireflies_v2: FirefliesIcon, gamma: GammaIcon, diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index 9d33239f918..d67628c4ab8 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -4074,6 +4074,69 @@ "integrationTypes": ["file-storage", "documents"], "tags": ["document-processing"] }, + { + "type": "findymail", + "slug": "findymail", + "name": "Findymail", + "description": "Find and verify B2B emails, phones, employees, and company data", + "longDescription": "Integrate Findymail to find verified work emails by name, domain, or LinkedIn URL, verify deliverability, reverse-lookup profiles from emails, enrich company data, find employees by job title, look up phone numbers, search technology stacks, and check credit usage.", + "bgColor": "#FFFFFF", + "iconName": "FindymailIcon", + "docsUrl": "https://docs.sim.ai/tools/findymail", + "operations": [ + { + "name": "Find Email From Name", + "description": "Find someone" + }, + { + "name": "Find Email From LinkedIn", + "description": "Find someone" + }, + { + "name": "Find Emails By Domain", + "description": "Find verified contacts at a given domain matching one or more target roles (max 3 roles). Limited to 5 concurrent synchronous requests." + }, + { + "name": "Verify Email", + "description": "Verifies the deliverability of an email address. Uses one verifier credit." + }, + { + "name": "Reverse Email Lookup", + "description": "Find a business profile from an email address. Uses 1 finder credit if a profile is found, 2 credits if returning full profile data." + }, + { + "name": "Get Company Info", + "description": "Retrieve company information from a LinkedIn URL, domain, or company name. Uses 1 finder credit per successful response." + }, + { + "name": "Find Employees", + "description": "Find employees at a company by website and target job titles. Uses 1 credit per found contact. Does not return email addresses." + }, + { + "name": "Find Phone", + "description": "Find someone" + }, + { + "name": "Search Technologies", + "description": "Search the technology catalog by name. Returns up to 25 technologies. Free endpoint, rate limited to 10 requests per minute." + }, + { + "name": "Lookup Technologies By Domain", + "description": "Get the technology stack for a company by domain. Optionally filter by technology names. 1 finder credit if technologies are found, free otherwise." + }, + { + "name": "Get Remaining Credits", + "description": "Retrieve the remaining finder and verifier credits for the authenticated account." + } + ], + "operationCount": 11, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationTypes": ["sales"], + "tags": ["enrichment", "sales-engagement"] + }, { "type": "firecrawl", "slug": "firecrawl", diff --git a/apps/sim/blocks/blocks/findymail.ts b/apps/sim/blocks/blocks/findymail.ts new file mode 100644 index 00000000000..d5f11bc7431 --- /dev/null +++ b/apps/sim/blocks/blocks/findymail.ts @@ -0,0 +1,345 @@ +import { FindymailIcon } from '@/components/icons' +import { AuthMode, type BlockConfig, IntegrationType } from '@/blocks/types' +import type { FindymailResponse } from '@/tools/findymail/types' + +export const FindymailBlock: BlockConfig = { + type: 'findymail', + name: 'Findymail', + description: 'Find and verify B2B emails, phones, employees, and company data', + authMode: AuthMode.ApiKey, + longDescription: + 'Integrate Findymail to find verified work emails by name, domain, or LinkedIn URL, verify deliverability, reverse-lookup profiles from emails, enrich company data, find employees by job title, look up phone numbers, search technology stacks, and check credit usage.', + docsLink: 'https://docs.sim.ai/tools/findymail', + category: 'tools', + integrationType: IntegrationType.Sales, + tags: ['enrichment', 'sales-engagement'], + bgColor: '#FFFFFF', + icon: FindymailIcon, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Find Email From Name', id: 'findymail_find_email_from_name' }, + { label: 'Find Email From LinkedIn', id: 'findymail_find_email_from_linkedin' }, + { label: 'Find Emails By Domain', id: 'findymail_find_emails_by_domain' }, + { label: 'Verify Email', id: 'findymail_verify_email' }, + { label: 'Reverse Email Lookup', id: 'findymail_reverse_email_lookup' }, + { label: 'Get Company Info', id: 'findymail_get_company' }, + { label: 'Find Employees', id: 'findymail_find_employees' }, + { label: 'Find Phone', id: 'findymail_find_phone' }, + { label: 'Search Technologies', id: 'findymail_search_technologies' }, + { label: 'Lookup Technologies By Domain', id: 'findymail_lookup_technologies' }, + { label: 'Get Remaining Credits', id: 'findymail_get_credits' }, + ], + value: () => 'findymail_find_email_from_name', + }, + // Find Email From Name + { + id: 'name', + title: 'Full Name', + type: 'short-input', + required: true, + placeholder: 'John Doe', + condition: { field: 'operation', value: 'findymail_find_email_from_name' }, + }, + { + id: 'domain', + title: 'Company Domain or Name', + type: 'short-input', + required: true, + placeholder: 'stripe.com', + condition: { field: 'operation', value: 'findymail_find_email_from_name' }, + }, + // Find Email From LinkedIn + { + id: 'linkedin_url', + title: 'LinkedIn URL', + type: 'short-input', + required: true, + placeholder: 'https://linkedin.com/in/johndoe', + condition: { field: 'operation', value: 'findymail_find_email_from_linkedin' }, + }, + // Find Emails By Domain + { + id: 'domain', + title: 'Domain', + type: 'short-input', + required: true, + placeholder: 'stripe.com', + condition: { field: 'operation', value: 'findymail_find_emails_by_domain' }, + }, + { + id: 'roles', + title: 'Target Roles', + type: 'long-input', + required: true, + placeholder: '["CEO", "Founder"]', + condition: { field: 'operation', value: 'findymail_find_emails_by_domain' }, + }, + // Verify Email + { + id: 'email', + title: 'Email Address', + type: 'short-input', + required: true, + placeholder: 'john@example.com', + condition: { field: 'operation', value: 'findymail_verify_email' }, + }, + // Reverse Email Lookup + { + id: 'email', + title: 'Email Address', + type: 'short-input', + required: true, + placeholder: 'john@example.com', + condition: { field: 'operation', value: 'findymail_reverse_email_lookup' }, + }, + { + id: 'with_profile', + title: 'Return Full Profile', + type: 'switch', + condition: { field: 'operation', value: 'findymail_reverse_email_lookup' }, + mode: 'advanced', + }, + // Get Company Info — provide at least one of LinkedIn URL, domain, or name + { + id: 'linkedin_url', + title: 'Company LinkedIn URL', + type: 'short-input', + placeholder: 'LinkedIn URL (at least one of URL/domain/name required)', + condition: { field: 'operation', value: 'findymail_get_company' }, + }, + { + id: 'domain', + title: 'Company Domain', + type: 'short-input', + placeholder: 'stripe.com (at least one of URL/domain/name required)', + condition: { field: 'operation', value: 'findymail_get_company' }, + }, + { + id: 'name', + title: 'Company Name', + type: 'short-input', + placeholder: 'Stripe (at least one of URL/domain/name required)', + condition: { field: 'operation', value: 'findymail_get_company' }, + }, + // Find Employees + { + id: 'website', + title: 'Company Website', + type: 'short-input', + required: true, + placeholder: 'google.com', + condition: { field: 'operation', value: 'findymail_find_employees' }, + }, + { + id: 'job_titles', + title: 'Job Titles', + type: 'long-input', + required: true, + placeholder: '["Software Engineer", "CEO"]', + condition: { field: 'operation', value: 'findymail_find_employees' }, + }, + { + id: 'count', + title: 'Number of Contacts', + type: 'short-input', + placeholder: '1', + condition: { field: 'operation', value: 'findymail_find_employees' }, + mode: 'advanced', + }, + // Find Phone + { + id: 'linkedin_url', + title: 'LinkedIn URL', + type: 'short-input', + required: true, + placeholder: 'https://linkedin.com/in/johndoe', + condition: { field: 'operation', value: 'findymail_find_phone' }, + }, + // Search Technologies + { + id: 'q', + title: 'Search Term', + type: 'short-input', + required: true, + placeholder: 'React', + condition: { field: 'operation', value: 'findymail_search_technologies' }, + }, + // Lookup Technologies By Domain + { + id: 'domain', + title: 'Company Domain', + type: 'short-input', + required: true, + placeholder: 'stripe.com', + condition: { field: 'operation', value: 'findymail_lookup_technologies' }, + }, + { + id: 'technologies', + title: 'Filter Technologies', + type: 'long-input', + placeholder: '["React", "TypeScript"]', + condition: { field: 'operation', value: 'findymail_lookup_technologies' }, + mode: 'advanced', + }, + // API Key + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + required: true, + placeholder: 'Enter your Findymail API key', + password: true, + }, + ], + tools: { + access: [ + 'findymail_verify_email', + 'findymail_find_email_from_name', + 'findymail_find_emails_by_domain', + 'findymail_find_email_from_linkedin', + 'findymail_reverse_email_lookup', + 'findymail_get_company', + 'findymail_find_employees', + 'findymail_find_phone', + 'findymail_search_technologies', + 'findymail_lookup_technologies', + 'findymail_get_credits', + ], + config: { + tool: (params) => { + switch (params.operation) { + case 'findymail_verify_email': + case 'findymail_find_email_from_name': + case 'findymail_find_emails_by_domain': + case 'findymail_find_email_from_linkedin': + case 'findymail_reverse_email_lookup': + case 'findymail_get_company': + case 'findymail_find_employees': + case 'findymail_find_phone': + case 'findymail_search_technologies': + case 'findymail_lookup_technologies': + case 'findymail_get_credits': + return params.operation + default: + return 'findymail_find_email_from_name' + } + }, + params: (params) => { + const { operation: _operation, ...rest } = params + const result: Record = {} + for (const [key, value] of Object.entries(rest)) { + if (value === undefined || value === null || value === '') continue + if (key === 'count') { + const n = Number(value) + if (!Number.isNaN(n)) result[key] = n + } else if (key === 'roles' || key === 'job_titles' || key === 'technologies') { + if (Array.isArray(value)) { + result[key] = value + } else if (typeof value === 'string') { + const trimmed = value.trim() + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed) + if (Array.isArray(parsed)) { + result[key] = parsed + continue + } + } catch { + // fall through to comma-split + } + } + result[key] = trimmed + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + } + } else { + result[key] = value + } + } + return result + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + apiKey: { type: 'string', description: 'Findymail API key' }, + email: { type: 'string', description: 'Email address' }, + name: { type: 'string', description: 'Full name or company name' }, + domain: { type: 'string', description: 'Company domain' }, + linkedin_url: { type: 'string', description: 'LinkedIn profile or company URL' }, + roles: { type: 'array', description: 'Target roles (max 3)' }, + with_profile: { type: 'boolean', description: 'Return full profile data on reverse lookup' }, + website: { type: 'string', description: 'Company website for employee search' }, + job_titles: { type: 'array', description: 'Target job titles (max 10)' }, + count: { type: 'number', description: 'Number of contacts to return (max 5)' }, + q: { type: 'string', description: 'Technology search query' }, + technologies: { type: 'array', description: 'Technology names to filter by' }, + }, + outputs: { + // Verify Email + verified: { type: 'boolean', description: 'Whether the email is deliverable' }, + provider: { type: 'string', description: 'Email service provider' }, + // Find / Reverse / Company + contact: { + type: 'json', + description: 'Contact found (name, email, domain)', + }, + contacts: { + type: 'array', + description: 'Contacts found at the domain (name, email, domain)', + }, + linkedin_url: { type: 'string', description: 'LinkedIn URL' }, + fullName: { type: 'string', description: 'Full name from LinkedIn profile' }, + username: { type: 'string', description: 'LinkedIn username' }, + headline: { type: 'string', description: 'Profile headline' }, + jobTitle: { type: 'string', description: 'Job title' }, + summary: { type: 'string', description: 'Profile summary' }, + city: { type: 'string', description: 'City' }, + region: { type: 'string', description: 'Region/state' }, + country: { type: 'string', description: 'Country' }, + companyLinkedinUrl: { type: 'string', description: 'Current company LinkedIn URL' }, + companyName: { type: 'string', description: 'Current company name' }, + companyWebsite: { type: 'string', description: 'Current company website' }, + isPremium: { type: 'boolean', description: 'Whether the profile has LinkedIn Premium' }, + isOpenProfile: { type: 'boolean', description: 'Whether the profile is open' }, + skills: { type: 'array', description: 'Profile skills' }, + jobs: { type: 'array', description: 'Job history entries' }, + educations: { + type: 'array', + description: 'Education history (school, degree, fieldOfStudy, startDate, endDate)', + }, + certificates: { + type: 'array', + description: 'Certifications (name, issuingOrganization, issueDate, expirationDate)', + }, + // Get Company + name: { type: 'string', description: 'Company name (get-company)' }, + domain: { type: 'string', description: 'Company domain' }, + company_size: { type: 'string', description: 'Headcount range (e.g., 1001-5000)' }, + industry: { type: 'string', description: 'Industry classification' }, + description: { type: 'string', description: 'Company description' }, + // Find Employees + employees: { + type: 'array', + description: 'Employees found (name, linkedinUrl, companyWebsite, companyName, jobTitle)', + }, + // Find Phone + phone: { type: 'string', description: 'Phone number in E.164 format (US only)' }, + line_type: { type: 'string', description: 'Phone line type (Mobile, Landline)' }, + // Technologies + technologies: { + type: 'array', + description: 'Technologies (name, category, subcategory, last_detected_at)', + }, + // Get Credits + credits: { type: 'number', description: 'Remaining finder credits' }, + verifier_credits: { type: 'number', description: 'Remaining verifier credits' }, + // Reverse / Verify shared + email: { type: 'string', description: 'Email address' }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 2038e1f2792..268e010543f 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -56,6 +56,7 @@ import { ExaBlock } from '@/blocks/blocks/exa' import { ExtendBlock, ExtendV2Block } from '@/blocks/blocks/extend' import { FathomBlock } from '@/blocks/blocks/fathom' import { FileBlock, FileV2Block, FileV3Block, FileV4Block } from '@/blocks/blocks/file' +import { FindymailBlock } from '@/blocks/blocks/findymail' import { FirecrawlBlock } from '@/blocks/blocks/firecrawl' import { FirefliesBlock, FirefliesV2Block } from '@/blocks/blocks/fireflies' import { FunctionBlock } from '@/blocks/blocks/function' @@ -301,6 +302,7 @@ export const registry: Record = { file_v2: FileV2Block, file_v3: FileV3Block, file_v4: FileV4Block, + findymail: FindymailBlock, firecrawl: FirecrawlBlock, fireflies: FirefliesBlock, fireflies_v2: FirefliesV2Block, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 6f07869ffb3..81dbe58d948 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -2283,6 +2283,49 @@ export function ElevenLabsIcon(props: SVGProps) { ) } +export function FindymailIcon(props: SVGProps) { + return ( + + + + + + + + + + + + + + + + ) +} export function FathomIcon(props: SVGProps) { return ( diff --git a/apps/sim/tools/findymail/find_email_from_linkedin.ts b/apps/sim/tools/findymail/find_email_from_linkedin.ts new file mode 100644 index 00000000000..8193035fc41 --- /dev/null +++ b/apps/sim/tools/findymail/find_email_from_linkedin.ts @@ -0,0 +1,75 @@ +import type { + FindymailFindEmailFromLinkedInParams, + FindymailFindEmailFromLinkedInResponse, +} from '@/tools/findymail/types' +import { FINDYMAIL_CONTACT_OUTPUT } from '@/tools/findymail/types' +import type { ToolConfig } from '@/tools/types' + +export const findEmailFromLinkedInTool: ToolConfig< + FindymailFindEmailFromLinkedInParams, + FindymailFindEmailFromLinkedInResponse +> = { + id: 'findymail_find_email_from_linkedin', + name: 'Findymail Find Email From LinkedIn', + description: + "Find someone's email from a LinkedIn profile URL or username. Uses one finder credit when a verified email is found.", + version: '1.0.0', + + params: { + linkedin_url: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + "Person's LinkedIn URL or username (e.g., 'https://linkedin.com/in/johndoe' or 'johndoe')", + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Findymail API Key', + }, + }, + + request: { + url: 'https://app.findymail.com/api/search/business-profile', + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => ({ linkedin_url: params.linkedin_url }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + return { + success: false, + error: + (errorData as Record).message || + `Findymail API error: ${response.status} ${response.statusText}`, + output: { contact: null }, + } + } + const data = await response.json() + const contact = data.contact ?? data.payload?.contact ?? null + return { + success: true, + output: { + contact: contact + ? { + name: contact.name ?? '', + email: contact.email ?? '', + domain: contact.domain ?? '', + } + : null, + }, + } + }, + + outputs: { + contact: { ...FINDYMAIL_CONTACT_OUTPUT, optional: true }, + }, +} diff --git a/apps/sim/tools/findymail/find_email_from_name.ts b/apps/sim/tools/findymail/find_email_from_name.ts new file mode 100644 index 00000000000..048a885e385 --- /dev/null +++ b/apps/sim/tools/findymail/find_email_from_name.ts @@ -0,0 +1,80 @@ +import type { + FindymailFindEmailFromNameParams, + FindymailFindEmailFromNameResponse, +} from '@/tools/findymail/types' +import { FINDYMAIL_CONTACT_OUTPUT } from '@/tools/findymail/types' +import type { ToolConfig } from '@/tools/types' + +export const findEmailFromNameTool: ToolConfig< + FindymailFindEmailFromNameParams, + FindymailFindEmailFromNameResponse +> = { + id: 'findymail_find_email_from_name', + name: 'Findymail Find Email From Name', + description: + "Find someone's email from their name and a company domain or company name. Uses one finder credit when a verified email is found.", + version: '1.0.0', + + params: { + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Person's full name (e.g., 'John Doe')", + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Company domain (preferred) or company name (e.g., stripe.com)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Findymail API Key', + }, + }, + + request: { + url: 'https://app.findymail.com/api/search/name', + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => ({ name: params.name, domain: params.domain }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + return { + success: false, + error: + (errorData as Record).message || + `Findymail API error: ${response.status} ${response.statusText}`, + output: { contact: null }, + } + } + const data = await response.json() + const contact = data.contact ?? data.payload?.contact ?? null + return { + success: true, + output: { + contact: contact + ? { + name: contact.name ?? '', + email: contact.email ?? '', + domain: contact.domain ?? '', + } + : null, + }, + } + }, + + outputs: { + contact: { ...FINDYMAIL_CONTACT_OUTPUT, optional: true }, + }, +} diff --git a/apps/sim/tools/findymail/find_emails_by_domain.ts b/apps/sim/tools/findymail/find_emails_by_domain.ts new file mode 100644 index 00000000000..5c726c01847 --- /dev/null +++ b/apps/sim/tools/findymail/find_emails_by_domain.ts @@ -0,0 +1,77 @@ +import type { + FindymailFindEmailsByDomainParams, + FindymailFindEmailsByDomainResponse, +} from '@/tools/findymail/types' +import { FINDYMAIL_CONTACTS_OUTPUT } from '@/tools/findymail/types' +import type { ToolConfig } from '@/tools/types' + +export const findEmailsByDomainTool: ToolConfig< + FindymailFindEmailsByDomainParams, + FindymailFindEmailsByDomainResponse +> = { + id: 'findymail_find_emails_by_domain', + name: 'Findymail Find Emails By Domain', + description: + 'Find verified contacts at a given domain matching one or more target roles (max 3 roles). Limited to 5 concurrent synchronous requests.', + version: '1.0.0', + + params: { + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Company domain (e.g., stripe.com)', + }, + roles: { + type: 'array', + required: true, + visibility: 'user-or-llm', + description: 'Target roles at the company (max 3, e.g., ["CEO", "Founder"])', + items: { type: 'string' }, + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Findymail API Key', + }, + }, + + request: { + url: 'https://app.findymail.com/api/search/domain', + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => ({ domain: params.domain, roles: params.roles }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + return { + success: false, + error: + (errorData as Record).message || + `Findymail API error: ${response.status} ${response.statusText}`, + output: { contacts: [] }, + } + } + const data = await response.json() + const raw = data.contacts ?? data.payload?.contacts ?? [] + const contacts = Array.isArray(raw) + ? raw.map((c: { name?: string; email?: string; domain?: string }) => ({ + name: c.name ?? '', + email: c.email ?? '', + domain: c.domain ?? '', + })) + : [] + return { success: true, output: { contacts } } + }, + + outputs: { + contacts: FINDYMAIL_CONTACTS_OUTPUT, + }, +} diff --git a/apps/sim/tools/findymail/find_employees.ts b/apps/sim/tools/findymail/find_employees.ts new file mode 100644 index 00000000000..8cfcf208b51 --- /dev/null +++ b/apps/sim/tools/findymail/find_employees.ts @@ -0,0 +1,100 @@ +import type { + FindymailFindEmployeesParams, + FindymailFindEmployeesResponse, +} from '@/tools/findymail/types' +import { FINDYMAIL_EMPLOYEES_OUTPUT } from '@/tools/findymail/types' +import type { ToolConfig } from '@/tools/types' + +export const findEmployeesTool: ToolConfig< + FindymailFindEmployeesParams, + FindymailFindEmployeesResponse +> = { + id: 'findymail_find_employees', + name: 'Findymail Find Employees', + description: + 'Find employees at a company by website and target job titles. Uses 1 credit per found contact. Does not return email addresses.', + version: '1.0.0', + + params: { + website: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Company website or domain (e.g., google.com)', + }, + job_titles: { + type: 'array', + required: true, + visibility: 'user-or-llm', + description: 'Target job titles to search for (max 10, e.g., ["Software Engineer", "CEO"])', + items: { type: 'string' }, + }, + count: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of contacts to return (max 5, default 1)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Findymail API Key', + }, + }, + + request: { + url: 'https://app.findymail.com/api/search/employees', + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => { + const body: Record = { + website: params.website, + job_titles: params.job_titles, + } + if (params.count !== undefined) body.count = params.count + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + return { + success: false, + error: + (errorData as Record).message || + `Findymail API error: ${response.status} ${response.statusText}`, + output: { employees: [] }, + } + } + const data = await response.json() + const raw = Array.isArray(data) ? data : (data.data ?? []) + const employees = Array.isArray(raw) + ? raw.map( + (e: { + name?: string + linkedinUrl?: string + companyWebsite?: string + companyName?: string + jobTitle?: string + }) => ({ + name: e.name ?? '', + linkedinUrl: e.linkedinUrl ?? null, + companyWebsite: e.companyWebsite ?? null, + companyName: e.companyName ?? null, + jobTitle: e.jobTitle ?? null, + }) + ) + : [] + return { success: true, output: { employees } } + }, + + outputs: { + employees: FINDYMAIL_EMPLOYEES_OUTPUT, + }, +} diff --git a/apps/sim/tools/findymail/find_phone.ts b/apps/sim/tools/findymail/find_phone.ts new file mode 100644 index 00000000000..6405401335a --- /dev/null +++ b/apps/sim/tools/findymail/find_phone.ts @@ -0,0 +1,71 @@ +import type { FindymailFindPhoneParams, FindymailFindPhoneResponse } from '@/tools/findymail/types' +import type { ToolConfig } from '@/tools/types' + +export const findPhoneTool: ToolConfig = { + id: 'findymail_find_phone', + name: 'Findymail Find Phone', + description: + "Find someone's phone number from a LinkedIn profile URL. Uses 10 finder credits if a phone is found. EU citizens are excluded for legal reasons.", + version: '1.0.0', + + params: { + linkedin_url: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + "Person's LinkedIn URL or username (e.g., 'https://linkedin.com/in/johndoe' or 'johndoe')", + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Findymail API Key', + }, + }, + + request: { + url: 'https://app.findymail.com/api/search/phone', + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => ({ linkedin_url: params.linkedin_url }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + return { + success: false, + error: + (errorData as Record).message || + `Findymail API error: ${response.status} ${response.statusText}`, + output: { phone: null, line_type: null }, + } + } + const data = await response.json() + return { + success: true, + output: { + phone: data.phone ?? null, + line_type: data.line_type ?? null, + }, + } + }, + + outputs: { + phone: { + type: 'string', + description: 'Phone number in E.164 format. Only available for US numbers.', + optional: true, + }, + line_type: { + type: 'string', + description: 'Phone line type (e.g., "Mobile", "Landline")', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/findymail/get_company.ts b/apps/sim/tools/findymail/get_company.ts new file mode 100644 index 00000000000..7a1c10cb244 --- /dev/null +++ b/apps/sim/tools/findymail/get_company.ts @@ -0,0 +1,102 @@ +import type { + FindymailGetCompanyParams, + FindymailGetCompanyResponse, +} from '@/tools/findymail/types' +import type { ToolConfig } from '@/tools/types' + +export const getCompanyTool: ToolConfig = { + id: 'findymail_get_company', + name: 'Findymail Get Company', + description: + 'Retrieve company information from a LinkedIn URL, domain, or company name. Uses 1 finder credit per successful response.', + version: '1.0.0', + + params: { + linkedin_url: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company LinkedIn URL (e.g., https://www.linkedin.com/company/stripe/)', + }, + domain: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company domain (e.g., stripe.com)', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company name (e.g., Stripe)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Findymail API Key', + }, + }, + + request: { + url: 'https://app.findymail.com/api/search/company', + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => { + const body: Record = {} + if (params.linkedin_url) body.linkedin_url = params.linkedin_url + if (params.domain) body.domain = params.domain + if (params.name) body.name = params.name + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + return { + success: false, + error: + (errorData as Record).message || + `Findymail API error: ${response.status} ${response.statusText}`, + output: { + name: null, + domain: null, + company_size: null, + industry: null, + linkedin_url: null, + description: null, + }, + } + } + const data = await response.json() + return { + success: true, + output: { + name: data.name ?? null, + domain: data.domain ?? null, + company_size: data.company_size ?? null, + industry: data.industry ?? null, + linkedin_url: data.linkedin_url ?? null, + description: data.description ?? null, + }, + } + }, + + outputs: { + name: { type: 'string', description: 'Company name', optional: true }, + domain: { type: 'string', description: 'Company domain', optional: true }, + company_size: { + type: 'string', + description: 'Employee headcount range (e.g., 1001-5000)', + optional: true, + }, + industry: { type: 'string', description: 'Industry classification', optional: true }, + linkedin_url: { type: 'string', description: 'Company LinkedIn URL', optional: true }, + description: { type: 'string', description: 'Company description', optional: true }, + }, +} diff --git a/apps/sim/tools/findymail/get_credits.ts b/apps/sim/tools/findymail/get_credits.ts new file mode 100644 index 00000000000..1bdfef5f0b9 --- /dev/null +++ b/apps/sim/tools/findymail/get_credits.ts @@ -0,0 +1,56 @@ +import type { + FindymailGetCreditsParams, + FindymailGetCreditsResponse, +} from '@/tools/findymail/types' +import type { ToolConfig } from '@/tools/types' + +export const getCreditsTool: ToolConfig = { + id: 'findymail_get_credits', + name: 'Findymail Get Credits', + description: 'Retrieve the remaining finder and verifier credits for the authenticated account.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Findymail API Key', + }, + }, + + request: { + url: 'https://app.findymail.com/api/credits', + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + Accept: 'application/json', + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + return { + success: false, + error: + (errorData as Record).message || + `Findymail API error: ${response.status} ${response.statusText}`, + output: { credits: 0, verifier_credits: 0 }, + } + } + const data = await response.json() + return { + success: true, + output: { + credits: data.credits ?? 0, + verifier_credits: data.verifier_credits ?? 0, + }, + } + }, + + outputs: { + credits: { type: 'number', description: 'Remaining finder credits' }, + verifier_credits: { type: 'number', description: 'Remaining verifier credits' }, + }, +} diff --git a/apps/sim/tools/findymail/index.ts b/apps/sim/tools/findymail/index.ts new file mode 100644 index 00000000000..759ee4c5f0e --- /dev/null +++ b/apps/sim/tools/findymail/index.ts @@ -0,0 +1,23 @@ +import { findEmailFromLinkedInTool } from '@/tools/findymail/find_email_from_linkedin' +import { findEmailFromNameTool } from '@/tools/findymail/find_email_from_name' +import { findEmailsByDomainTool } from '@/tools/findymail/find_emails_by_domain' +import { findEmployeesTool } from '@/tools/findymail/find_employees' +import { findPhoneTool } from '@/tools/findymail/find_phone' +import { getCompanyTool } from '@/tools/findymail/get_company' +import { getCreditsTool } from '@/tools/findymail/get_credits' +import { lookupTechnologiesTool } from '@/tools/findymail/lookup_technologies' +import { reverseEmailLookupTool } from '@/tools/findymail/reverse_email_lookup' +import { searchTechnologiesTool } from '@/tools/findymail/search_technologies' +import { verifyEmailTool } from '@/tools/findymail/verify_email' + +export const findymailVerifyEmailTool = verifyEmailTool +export const findymailFindEmailFromNameTool = findEmailFromNameTool +export const findymailFindEmailsByDomainTool = findEmailsByDomainTool +export const findymailFindEmailFromLinkedInTool = findEmailFromLinkedInTool +export const findymailReverseEmailLookupTool = reverseEmailLookupTool +export const findymailGetCompanyTool = getCompanyTool +export const findymailFindEmployeesTool = findEmployeesTool +export const findymailFindPhoneTool = findPhoneTool +export const findymailSearchTechnologiesTool = searchTechnologiesTool +export const findymailLookupTechnologiesTool = lookupTechnologiesTool +export const findymailGetCreditsTool = getCreditsTool diff --git a/apps/sim/tools/findymail/lookup_technologies.ts b/apps/sim/tools/findymail/lookup_technologies.ts new file mode 100644 index 00000000000..a662f220eb6 --- /dev/null +++ b/apps/sim/tools/findymail/lookup_technologies.ts @@ -0,0 +1,98 @@ +import type { + FindymailLookupTechnologiesParams, + FindymailLookupTechnologiesResponse, +} from '@/tools/findymail/types' +import { FINDYMAIL_TECHNOLOGIES_OUTPUT } from '@/tools/findymail/types' +import type { ToolConfig } from '@/tools/types' + +export const lookupTechnologiesTool: ToolConfig< + FindymailLookupTechnologiesParams, + FindymailLookupTechnologiesResponse +> = { + id: 'findymail_lookup_technologies', + name: 'Findymail Lookup Technologies', + description: + 'Get the technology stack for a company by domain. Optionally filter by technology names. 1 finder credit if technologies are found, free otherwise.', + version: '1.0.0', + + params: { + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Company domain to look up (e.g., stripe.com)', + }, + technologies: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Filter by technology names, case-insensitive (e.g., ["React", "TypeScript"])', + items: { type: 'string' }, + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Findymail API Key', + }, + }, + + request: { + url: 'https://app.findymail.com/api/technologies', + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => { + const body: Record = { domain: params.domain } + if (params.technologies && params.technologies.length > 0) { + body.technologies = params.technologies + } + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + return { + success: false, + error: + (errorData as Record).message || + `Findymail API error: ${response.status} ${response.statusText}`, + output: { domain: '', technologies: [] }, + } + } + const data = await response.json() + const raw = data.technologies ?? [] + const technologies = Array.isArray(raw) + ? raw.map( + (t: { + name?: string + category?: string + subcategory?: string + last_detected_at?: string + }) => ({ + name: t.name ?? '', + category: t.category ?? null, + subcategory: t.subcategory ?? null, + last_detected_at: t.last_detected_at ?? null, + }) + ) + : [] + return { + success: true, + output: { + domain: data.domain ?? '', + technologies, + }, + } + }, + + outputs: { + domain: { type: 'string', description: 'The resolved company domain' }, + technologies: FINDYMAIL_TECHNOLOGIES_OUTPUT, + }, +} diff --git a/apps/sim/tools/findymail/reverse_email_lookup.ts b/apps/sim/tools/findymail/reverse_email_lookup.ts new file mode 100644 index 00000000000..3808a3a6324 --- /dev/null +++ b/apps/sim/tools/findymail/reverse_email_lookup.ts @@ -0,0 +1,149 @@ +import type { + FindymailReverseEmailLookupParams, + FindymailReverseEmailLookupResponse, +} from '@/tools/findymail/types' +import type { ToolConfig } from '@/tools/types' + +export const reverseEmailLookupTool: ToolConfig< + FindymailReverseEmailLookupParams, + FindymailReverseEmailLookupResponse +> = { + id: 'findymail_reverse_email_lookup', + name: 'Findymail Reverse Email Lookup', + description: + 'Find a business profile from an email address. Uses 1 finder credit if a profile is found, 2 credits if returning full profile data.', + version: '1.0.0', + + params: { + email: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Work or personal email address to look up', + }, + with_profile: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to return enriched profile metadata (default: false)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Findymail API Key', + }, + }, + + request: { + url: 'https://app.findymail.com/api/search/reverse-email', + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => ({ + email: params.email, + ...(params.with_profile ? { with_profile: true } : {}), + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + return { + success: false, + error: + (errorData as Record).message || + `Findymail API error: ${response.status} ${response.statusText}`, + output: { + email: null, + linkedin_url: null, + fullName: null, + username: null, + headline: null, + jobTitle: null, + summary: null, + city: null, + region: null, + country: null, + companyLinkedinUrl: null, + companyName: null, + companyWebsite: null, + isPremium: null, + isOpenProfile: null, + skills: [], + jobs: [], + educations: [], + certificates: [], + }, + } + } + const data = await response.json() + return { + success: true, + output: { + email: data.email ?? null, + linkedin_url: data.linkedin_url ?? null, + fullName: data.fullName ?? null, + username: data.username ?? null, + headline: data.headline ?? null, + jobTitle: data.jobTitle ?? null, + summary: data.summary ?? null, + city: data.city ?? null, + region: data.region ?? null, + country: data.country ?? null, + companyLinkedinUrl: data.companyLinkedinUrl ?? null, + companyName: data.companyName ?? null, + companyWebsite: data.companyWebsite ?? null, + isPremium: data.isPremium ?? null, + isOpenProfile: data.isOpenProfile ?? null, + skills: data.skills ?? [], + jobs: data.jobs ?? [], + educations: data.educations ?? [], + certificates: data.certificates ?? [], + }, + } + }, + + outputs: { + email: { type: 'string', description: 'The email address that was looked up', optional: true }, + linkedin_url: { type: 'string', description: 'LinkedIn profile URL', optional: true }, + fullName: { type: 'string', description: 'Full name from profile', optional: true }, + username: { type: 'string', description: 'LinkedIn username', optional: true }, + headline: { type: 'string', description: 'Profile headline', optional: true }, + jobTitle: { type: 'string', description: 'Current job title', optional: true }, + summary: { type: 'string', description: 'Profile summary', optional: true }, + city: { type: 'string', description: 'City', optional: true }, + region: { type: 'string', description: 'Region or state', optional: true }, + country: { type: 'string', description: 'Country', optional: true }, + companyLinkedinUrl: { + type: 'string', + description: 'Current company LinkedIn URL', + optional: true, + }, + companyName: { type: 'string', description: 'Current company name', optional: true }, + companyWebsite: { type: 'string', description: 'Current company website', optional: true }, + isPremium: { + type: 'boolean', + description: 'Whether the profile has LinkedIn Premium', + optional: true, + }, + isOpenProfile: { + type: 'boolean', + description: 'Whether the profile is an Open Profile', + optional: true, + }, + skills: { type: 'array', description: 'List of profile skills' }, + jobs: { type: 'array', description: 'Job history entries' }, + educations: { + type: 'array', + description: 'Education history (school, degree, fieldOfStudy, startDate, endDate)', + }, + certificates: { + type: 'array', + description: 'Certifications (name, issuingOrganization, issueDate, expirationDate)', + }, + }, +} diff --git a/apps/sim/tools/findymail/search_technologies.ts b/apps/sim/tools/findymail/search_technologies.ts new file mode 100644 index 00000000000..1c168c7b35d --- /dev/null +++ b/apps/sim/tools/findymail/search_technologies.ts @@ -0,0 +1,80 @@ +import type { + FindymailSearchTechnologiesParams, + FindymailSearchTechnologiesResponse, +} from '@/tools/findymail/types' +import { FINDYMAIL_TECHNOLOGIES_OUTPUT } from '@/tools/findymail/types' +import type { ToolConfig } from '@/tools/types' + +export const searchTechnologiesTool: ToolConfig< + FindymailSearchTechnologiesParams, + FindymailSearchTechnologiesResponse +> = { + id: 'findymail_search_technologies', + name: 'Findymail Search Technologies', + description: + 'Search the technology catalog by name. Returns up to 25 technologies. Free endpoint, rate limited to 10 requests per minute.', + version: '1.0.0', + + params: { + q: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Search term (min 2 characters, e.g., "React")', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Findymail API Key', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://app.findymail.com/api/technologies/search') + url.searchParams.append('q', params.q) + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + Accept: 'application/json', + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + return { + success: false, + error: + (errorData as Record).message || + `Findymail API error: ${response.status} ${response.statusText}`, + output: { technologies: [] }, + } + } + const data = await response.json() + const raw = data.data ?? [] + const technologies = Array.isArray(raw) + ? raw.map( + (t: { + name?: string + category?: string + subcategory?: string + last_detected_at?: string + }) => ({ + name: t.name ?? '', + category: t.category ?? null, + subcategory: t.subcategory ?? null, + last_detected_at: t.last_detected_at ?? null, + }) + ) + : [] + return { success: true, output: { technologies } } + }, + + outputs: { + technologies: FINDYMAIL_TECHNOLOGIES_OUTPUT, + }, +} diff --git a/apps/sim/tools/findymail/types.ts b/apps/sim/tools/findymail/types.ts new file mode 100644 index 00000000000..72ceab1c79a --- /dev/null +++ b/apps/sim/tools/findymail/types.ts @@ -0,0 +1,241 @@ +import type { OutputProperty, ToolResponse } from '@/tools/types' + +interface FindymailBaseParams { + apiKey: string +} + +export const FINDYMAIL_CONTACT_OUTPUT_PROPERTIES = { + name: { type: 'string', description: 'Contact full name' }, + email: { type: 'string', description: 'Contact email address' }, + domain: { type: 'string', description: 'Email domain' }, +} as const satisfies Record + +export const FINDYMAIL_CONTACT_OUTPUT: OutputProperty = { + type: 'object', + description: 'Contact information', + properties: FINDYMAIL_CONTACT_OUTPUT_PROPERTIES, +} + +export const FINDYMAIL_CONTACTS_OUTPUT: OutputProperty = { + type: 'array', + description: 'List of contacts found', + items: { + type: 'object', + properties: FINDYMAIL_CONTACT_OUTPUT_PROPERTIES, + }, +} + +export const FINDYMAIL_TECHNOLOGY_OUTPUT_PROPERTIES = { + name: { type: 'string', description: 'Technology name' }, + category: { type: 'string', description: 'Technology category' }, + subcategory: { type: 'string', description: 'Technology subcategory' }, + last_detected_at: { + type: 'string', + description: 'Last detection timestamp (ISO 8601)', + optional: true, + }, +} as const satisfies Record + +export const FINDYMAIL_TECHNOLOGIES_OUTPUT: OutputProperty = { + type: 'array', + description: 'List of technologies', + items: { + type: 'object', + properties: FINDYMAIL_TECHNOLOGY_OUTPUT_PROPERTIES, + }, +} + +export const FINDYMAIL_EMPLOYEE_OUTPUT_PROPERTIES = { + name: { type: 'string', description: 'Employee full name' }, + linkedinUrl: { type: 'string', description: 'LinkedIn profile URL', optional: true }, + companyWebsite: { type: 'string', description: 'Company website', optional: true }, + companyName: { type: 'string', description: 'Company name', optional: true }, + jobTitle: { type: 'string', description: 'Job title', optional: true }, +} as const satisfies Record + +export const FINDYMAIL_EMPLOYEES_OUTPUT: OutputProperty = { + type: 'array', + description: 'List of employees matching the search criteria', + items: { + type: 'object', + properties: FINDYMAIL_EMPLOYEE_OUTPUT_PROPERTIES, + }, +} + +export interface FindymailContact { + name: string + email: string + domain: string +} + +export interface FindymailVerifyEmailParams extends FindymailBaseParams { + email: string +} + +export interface FindymailVerifyEmailResponse extends ToolResponse { + output: { + email: string + verified: boolean + provider: string | null + } +} + +export interface FindymailFindEmailFromNameParams extends FindymailBaseParams { + name: string + domain: string +} + +export interface FindymailFindEmailFromNameResponse extends ToolResponse { + output: { + contact: FindymailContact | null + } +} + +export interface FindymailFindEmailsByDomainParams extends FindymailBaseParams { + domain: string + roles: string[] +} + +export interface FindymailFindEmailsByDomainResponse extends ToolResponse { + output: { + contacts: FindymailContact[] + } +} + +export interface FindymailFindEmailFromLinkedInParams extends FindymailBaseParams { + linkedin_url: string +} + +export interface FindymailFindEmailFromLinkedInResponse extends ToolResponse { + output: { + contact: FindymailContact | null + } +} + +export interface FindymailReverseEmailLookupParams extends FindymailBaseParams { + email: string + with_profile?: boolean +} + +export interface FindymailReverseEmailLookupResponse extends ToolResponse { + output: { + email: string | null + linkedin_url: string | null + fullName: string | null + username: string | null + headline: string | null + jobTitle: string | null + summary: string | null + city: string | null + region: string | null + country: string | null + companyLinkedinUrl: string | null + companyName: string | null + companyWebsite: string | null + isPremium: boolean | null + isOpenProfile: boolean | null + skills: unknown[] + jobs: unknown[] + educations: unknown[] + certificates: unknown[] + } +} + +export interface FindymailGetCompanyParams extends FindymailBaseParams { + linkedin_url?: string + domain?: string + name?: string +} + +export interface FindymailGetCompanyResponse extends ToolResponse { + output: { + name: string | null + domain: string | null + company_size: string | null + industry: string | null + linkedin_url: string | null + description: string | null + } +} + +export interface FindymailFindEmployeesParams extends FindymailBaseParams { + website: string + job_titles: string[] + count?: number +} + +export interface FindymailEmployee { + name: string + linkedinUrl: string | null + companyWebsite: string | null + companyName: string | null + jobTitle: string | null +} + +export interface FindymailFindEmployeesResponse extends ToolResponse { + output: { + employees: FindymailEmployee[] + } +} + +export interface FindymailFindPhoneParams extends FindymailBaseParams { + linkedin_url: string +} + +export interface FindymailFindPhoneResponse extends ToolResponse { + output: { + phone: string | null + line_type: string | null + } +} + +export interface FindymailSearchTechnologiesParams extends FindymailBaseParams { + q: string +} + +export interface FindymailTechnology { + name: string + category: string | null + subcategory: string | null + last_detected_at?: string | null +} + +export interface FindymailSearchTechnologiesResponse extends ToolResponse { + output: { + technologies: FindymailTechnology[] + } +} + +export interface FindymailLookupTechnologiesParams extends FindymailBaseParams { + domain: string + technologies?: string[] +} + +export interface FindymailLookupTechnologiesResponse extends ToolResponse { + output: { + domain: string + technologies: FindymailTechnology[] + } +} + +export interface FindymailGetCreditsParams extends FindymailBaseParams {} + +export interface FindymailGetCreditsResponse extends ToolResponse { + output: { + credits: number + verifier_credits: number + } +} + +export type FindymailResponse = + | FindymailVerifyEmailResponse + | FindymailFindEmailFromNameResponse + | FindymailFindEmailsByDomainResponse + | FindymailFindEmailFromLinkedInResponse + | FindymailReverseEmailLookupResponse + | FindymailGetCompanyResponse + | FindymailFindEmployeesResponse + | FindymailFindPhoneResponse + | FindymailSearchTechnologiesResponse + | FindymailLookupTechnologiesResponse + | FindymailGetCreditsResponse diff --git a/apps/sim/tools/findymail/verify_email.ts b/apps/sim/tools/findymail/verify_email.ts new file mode 100644 index 00000000000..ec5d97ad5d4 --- /dev/null +++ b/apps/sim/tools/findymail/verify_email.ts @@ -0,0 +1,71 @@ +import type { + FindymailVerifyEmailParams, + FindymailVerifyEmailResponse, +} from '@/tools/findymail/types' +import type { ToolConfig } from '@/tools/types' + +export const verifyEmailTool: ToolConfig = + { + id: 'findymail_verify_email', + name: 'Findymail Verify Email', + description: 'Verifies the deliverability of an email address. Uses one verifier credit.', + version: '1.0.0', + + params: { + email: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Email address to verify (e.g., john@example.com)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Findymail API Key', + }, + }, + + request: { + url: 'https://app.findymail.com/api/verify', + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => ({ email: params.email }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + return { + success: false, + error: + (errorData as Record).message || + `Findymail API error: ${response.status} ${response.statusText}`, + output: { email: '', verified: false, provider: null }, + } + } + const data = await response.json() + return { + success: true, + output: { + email: data.email ?? '', + verified: data.verified ?? false, + provider: data.provider ?? null, + }, + } + }, + + outputs: { + email: { type: 'string', description: 'The verified email address' }, + verified: { type: 'boolean', description: 'Whether the email is verified as deliverable' }, + provider: { + type: 'string', + description: 'Email service provider (e.g., Google, Microsoft)', + optional: true, + }, + }, + } diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 2f8b48ff3c2..dad88d1a940 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -653,6 +653,19 @@ import { fileReadTool, fileWriteTool, } from '@/tools/file' +import { + findymailFindEmailFromLinkedInTool, + findymailFindEmailFromNameTool, + findymailFindEmailsByDomainTool, + findymailFindEmployeesTool, + findymailFindPhoneTool, + findymailGetCompanyTool, + findymailGetCreditsTool, + findymailLookupTechnologiesTool, + findymailReverseEmailLookupTool, + findymailSearchTechnologiesTool, + findymailVerifyEmailTool, +} from '@/tools/findymail' import { firecrawlAgentTool, firecrawlCrawlTool, @@ -4823,6 +4836,17 @@ export const tools: Record = { fathom_get_transcript: fathomGetTranscriptTool, fathom_list_team_members: fathomListTeamMembersTool, fathom_list_teams: fathomListTeamsTool, + findymail_verify_email: findymailVerifyEmailTool, + findymail_find_email_from_name: findymailFindEmailFromNameTool, + findymail_find_emails_by_domain: findymailFindEmailsByDomainTool, + findymail_find_email_from_linkedin: findymailFindEmailFromLinkedInTool, + findymail_reverse_email_lookup: findymailReverseEmailLookupTool, + findymail_get_company: findymailGetCompanyTool, + findymail_find_employees: findymailFindEmployeesTool, + findymail_find_phone: findymailFindPhoneTool, + findymail_search_technologies: findymailSearchTechnologiesTool, + findymail_lookup_technologies: findymailLookupTechnologiesTool, + findymail_get_credits: findymailGetCreditsTool, stt_whisper: whisperSttTool, stt_whisper_v2: whisperSttV2Tool, stt_deepgram: deepgramSttTool, From 0c1167d76bd14bf6f0121ac6e92a0401b7641f8a Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 18 May 2026 17:49:51 -0700 Subject: [PATCH 08/10] improvement(workspace): fix resource table column proportions and toast stacking (#4655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(workspace): fix resource table column proportions and toast stacking * fix(resource): restore scrollbar-gutter stable on table scroll container * fix(resource): remove scrollbar-gutter stable — single-table layout doesn't need it * fixed files cols * fix(findymail): add required enabled field to wandConfig entries * fix(findymail): remove optional from block outputs — not valid on BlockConfig output type * fixes * fix(files): use folderSizeMap for sort value so size sort matches display * files ref --- apps/docs/components/ui/icon-mapping.ts | 1 - apps/docs/content/docs/de/tools/file.mdx | 2 +- apps/docs/content/docs/en/tools/prospeo.mdx | 4 +- apps/docs/content/docs/es/tools/file.mdx | 2 +- apps/docs/content/docs/fr/tools/file.mdx | 2 +- apps/docs/content/docs/ja/tools/file.mdx | 2 +- apps/docs/content/docs/zh/tools/file.mdx | 2 +- apps/sim/app/_styles/globals.css | 8 +- .../[workspaceId]/components/index.ts | 2 +- .../components/resource/resource.tsx | 141 +++++---------- .../workspace/[workspaceId]/files/files.tsx | 48 +++-- .../knowledge/[id]/[documentId]/document.tsx | 18 +- .../[workspaceId]/knowledge/[id]/base.tsx | 14 +- .../[workspaceId]/knowledge/knowledge.tsx | 23 ++- .../app/workspace/[workspaceId]/logs/logs.tsx | 10 +- .../scheduled-tasks/scheduled-tasks.tsx | 14 +- .../workspace/[workspaceId]/tables/tables.tsx | 10 +- apps/sim/blocks/blocks/findymail.ts | 142 +++++++++++---- apps/sim/blocks/blocks/prospeo.ts | 164 +++++++++++++----- .../emcn/components/toast/toast.tsx | 34 +++- apps/sim/tools/findymail/index.ts | 2 + apps/sim/tools/prospeo/bulk_enrich_person.ts | 6 +- apps/sim/tools/prospeo/enrich_person.ts | 6 +- apps/sim/tools/prospeo/search_suggestions.ts | 18 +- apps/sim/tools/prospeo/types.ts | 11 +- 25 files changed, 431 insertions(+), 255 deletions(-) diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 0de540e9e9f..dd5ab47b8cb 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -263,7 +263,6 @@ export const blockTypeToIconMap: Record = { extend_v2: ExtendIcon, fathom: FathomIcon, file: DocumentIcon, - file_v3: DocumentIcon, file_v4: DocumentIcon, findymail: FindymailIcon, firecrawl: FirecrawlIcon, diff --git a/apps/docs/content/docs/de/tools/file.mdx b/apps/docs/content/docs/de/tools/file.mdx index 2844895777e..54858d4b528 100644 --- a/apps/docs/content/docs/de/tools/file.mdx +++ b/apps/docs/content/docs/de/tools/file.mdx @@ -6,7 +6,7 @@ description: Mehrere Dateien lesen und parsen import { BlockInfoCard } from "@/components/ui/block-info-card" diff --git a/apps/docs/content/docs/en/tools/prospeo.mdx b/apps/docs/content/docs/en/tools/prospeo.mdx index 2072046a4a3..9e40823dfd9 100644 --- a/apps/docs/content/docs/en/tools/prospeo.mdx +++ b/apps/docs/content/docs/en/tools/prospeo.mdx @@ -229,9 +229,9 @@ Free endpoint to retrieve valid location or job title values for use in Search f | Parameter | Type | Description | | --------- | ---- | ----------- | -| `location_suggestions` | array | Location suggestions when using location_search \(null when searching job titles\) | +| `location_suggestions` | array | Location suggestions when using location_search \(empty when searching job titles\) | | ↳ `name` | string | Formatted location name to use in filters | | ↳ `type` | string | Location type \(COUNTRY, STATE, CITY, or ZONE\) | -| `job_title_suggestions` | array | Up to 25 job title suggestions ordered by popularity when using job_title_search \(null when searching locations\) | +| `job_title_suggestions` | array | Up to 25 job title suggestions ordered by popularity when using job_title_search \(empty when searching locations\) | diff --git a/apps/docs/content/docs/es/tools/file.mdx b/apps/docs/content/docs/es/tools/file.mdx index 852c5f6202e..f38e03ee15b 100644 --- a/apps/docs/content/docs/es/tools/file.mdx +++ b/apps/docs/content/docs/es/tools/file.mdx @@ -6,7 +6,7 @@ description: Leer y analizar múltiples archivos import { BlockInfoCard } from "@/components/ui/block-info-card" diff --git a/apps/docs/content/docs/fr/tools/file.mdx b/apps/docs/content/docs/fr/tools/file.mdx index b57c4e1a19a..32ac4fe9150 100644 --- a/apps/docs/content/docs/fr/tools/file.mdx +++ b/apps/docs/content/docs/fr/tools/file.mdx @@ -6,7 +6,7 @@ description: Lire et analyser plusieurs fichiers import { BlockInfoCard } from "@/components/ui/block-info-card" diff --git a/apps/docs/content/docs/ja/tools/file.mdx b/apps/docs/content/docs/ja/tools/file.mdx index 2dcf9d02580..a08d976cf77 100644 --- a/apps/docs/content/docs/ja/tools/file.mdx +++ b/apps/docs/content/docs/ja/tools/file.mdx @@ -6,7 +6,7 @@ description: 複数のファイルを読み込んで解析する import { BlockInfoCard } from "@/components/ui/block-info-card" diff --git a/apps/docs/content/docs/zh/tools/file.mdx b/apps/docs/content/docs/zh/tools/file.mdx index 2f923430ce4..a347387f19a 100644 --- a/apps/docs/content/docs/zh/tools/file.mdx +++ b/apps/docs/content/docs/zh/tools/file.mdx @@ -6,7 +6,7 @@ description: 读取并解析多个文件 import { BlockInfoCard } from "@/components/ui/block-info-card" diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index 1608115e584..15fb1c30cf3 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -878,22 +878,22 @@ input[type="search"]::-ms-clear { @keyframes toast-enter { from { opacity: 0; - transform: translateY(8px) scale(0.97); + transform: translateX(var(--stack-offset, 0px)) translateY(8px) scale(0.97); } to { opacity: 1; - transform: translateY(0) scale(1); + transform: translateX(var(--stack-offset, 0px)) translateY(0) scale(1); } } @keyframes toast-exit { from { opacity: 1; - transform: translateY(0) scale(1); + transform: translateX(var(--stack-offset, 0px)) translateY(0) scale(1); } to { opacity: 0; - transform: translateY(8px) scale(0.97); + transform: translateX(var(--stack-offset, 0px)) translateY(8px) scale(0.97); } } diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index 66ba3cfdecf..bc76e7d77ad 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -25,4 +25,4 @@ export type { RowDragDropConfig, SelectableConfig, } from './resource/resource' -export { Resource, ResourceTable } from './resource/resource' +export { EMPTY_CELL_PLACEHOLDER, Resource, ResourceTable } from './resource/resource' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index b0a149b5f1e..d2490ec46f6 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -23,8 +23,6 @@ export interface ResourceColumn { id: string header: string widthMultiplier?: number - /** Fixed pixel width. When set, the column is excluded from proportional sizing. */ - widthPx?: number } export interface ResourceCell { @@ -94,7 +92,7 @@ interface ResourceProps { overlay?: ReactNode } -const EMPTY_CELL_PLACEHOLDER = '- - -' +export const EMPTY_CELL_PLACEHOLDER = '—' const SKELETON_ROW_COUNT = 5 /** @@ -214,7 +212,6 @@ export const ResourceTable = memo(function ResourceTable({ emptyMessage, overlay, }: ResourceTableProps) { - const headerRef = useRef(null) const loadMoreRef = useRef(null) const sortEnabled = defaultSort != null const [internalSort, setInternalSort] = useState<{ column: string; direction: 'asc' | 'desc' }>({ @@ -222,12 +219,6 @@ export const ResourceTable = memo(function ResourceTable({ direction: 'desc', }) - const handleBodyScroll = useCallback((e: React.UIEvent) => { - if (headerRef.current) { - headerRef.current.scrollLeft = e.currentTarget.scrollLeft - } - }, []) - const handleSort = useCallback((column: string, direction: 'asc' | 'desc') => { setInternalSort({ column, direction }) }, []) @@ -290,10 +281,10 @@ export const ResourceTable = memo(function ResourceTable({ return (
-
+
- + {hasCheckbox && ( -
@@ -341,14 +332,6 @@ export const ResourceTable = memo(function ResourceTable({ })}
-
-
- - {displayRows.map((row) => ( sum + (col.widthPx ?? 0), 0) - const flexibleWeights = columns.map((col, colIdx) => - col.widthPx ? 0 : (colIdx === 0 ? 2.5 : 1.0) * (col.widthMultiplier ?? 1) + const weights = columns.map( + (col, colIdx) => (colIdx === 0 ? 2.5 : 1.0) * (col.widthMultiplier ?? 1) ) - const flexibleTotal = flexibleWeights.reduce((s, w) => s + w, 0) - const reservedPx = fixedPxTotal + (hasCheckbox ? CHECKBOX_COLUMN_WIDTH_PX : 0) - + const total = weights.reduce((s, w) => s + w, 0) return ( {hasCheckbox && } - {columns.map((col, colIdx) => { - if (col.widthPx) { - return - } - const columnRatio = flexibleTotal > 0 ? flexibleWeights[colIdx] / flexibleTotal : 0 - const columnPercent = columnRatio * 100 - const reservedOffset = reservedPx * columnRatio - - return ( - 0 - ? `calc(${columnPercent}% - ${reservedOffset}px)` - : `${columnPercent}%`, - }} - /> - ) - })} + {columns.map((col, colIdx) => ( + + ))} ) }) @@ -697,55 +659,48 @@ const DataTableSkeleton = memo(function DataTableSkeleton({ hasCheckbox, }: DataTableSkeletonProps) { return ( - <> -
-
- - - +
+
+ + + + {hasCheckbox && ( + + )} + {columns.map((col) => ( + + ))} + + + + {Array.from({ length: rowCount }, (_, i) => ( + {hasCheckbox && ( - )} - {columns.map((col) => ( - + {columns.map((col, colIdx) => ( + ))} - -
+ + +
+ +
+
+ - + -
- -
-
+ + {colIdx === 0 && } + + +
-
-
- - - - {Array.from({ length: rowCount }, (_, i) => ( - - {hasCheckbox && ( - - )} - {columns.map((col, colIdx) => ( - - ))} - - ))} - -
- - - - {colIdx === 0 && } - - -
-
- + ))} + + +
) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 6cf3f4f7285..1e715a8a373 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -56,6 +56,7 @@ import type { SortConfig, } from '@/app/workspace/[workspaceId]/components' import { + EMPTY_CELL_PLACEHOLDER, InlineRenameInput, ownerCell, Resource, @@ -112,12 +113,12 @@ const SUPPORTED_EXTENSIONS = [ const ACCEPT_ATTR = SUPPORTED_EXTENSIONS.map((ext) => `.${ext}`).join(',') const COLUMNS: ResourceColumn[] = [ - { id: 'name', header: 'Name' }, - { id: 'size', header: 'Size', widthPx: 110 }, - { id: 'type', header: 'Type', widthPx: 120 }, - { id: 'created', header: 'Created', widthPx: 150 }, - { id: 'owner', header: 'Owner', widthPx: 160 }, - { id: 'updated', header: 'Last Updated', widthPx: 150 }, + { id: 'name', header: 'Name', widthMultiplier: 1.15 }, + { id: 'size', header: 'Size', widthMultiplier: 0.85 }, + { id: 'type', header: 'Type', widthMultiplier: 1.0 }, + { id: 'created', header: 'Created' }, + { id: 'owner', header: 'Owner' }, + { id: 'updated', header: 'Last Updated' }, ] const MIME_TYPE_LABELS: Record = { @@ -301,6 +302,26 @@ export function Files() { const folderById = useMemo(() => new Map(folders.map((folder) => [folder.id, folder])), [folders]) const currentFolder = currentFolderId ? (folderById.get(currentFolderId) ?? null) : null + + const folderSizeMap = useMemo(() => { + const directSize = new Map() + for (const file of files) { + if (file.folderId) { + directSize.set(file.folderId, (directSize.get(file.folderId) ?? 0) + file.size) + } + } + const totalSize = new Map() + const getTotal = (folderId: string): number => { + if (totalSize.has(folderId)) return totalSize.get(folderId)! + const children = folders.filter((f) => f.parentId === folderId) + const size = + (directSize.get(folderId) ?? 0) + children.reduce((s, c) => s + getTotal(c.id), 0) + totalSize.set(folderId, size) + return size + } + for (const folder of folders) getTotal(folder.id) + return totalSize + }, [files, folders]) const currentFolderPath = currentFolder?.path ?? null const visibleFolders = useMemo(() => { @@ -406,7 +427,12 @@ export function Files() { icon: , label: folder.name, }, - size: { label: 'Folder' }, + size: { + label: + (folderSizeMap.get(folder.id) ?? 0) > 0 + ? formatFileSize(folderSizeMap.get(folder.id)!, { includeBytes: true }) + : EMPTY_CELL_PLACEHOLDER, + }, type: { icon: , label: 'Folder', @@ -417,7 +443,7 @@ export function Files() { }, sortValues: { name: folder.name, - size: -1, + size: folderSizeMap.get(folder.id) ?? -1, type: 'Folder', created: new Date(folder.createdAt).getTime(), updated: new Date(folder.updatedAt).getTime(), @@ -458,7 +484,7 @@ export function Files() { }) return [...folderRows, ...fileRows] - }, [visibleFolders, filteredFiles, members]) + }, [visibleFolders, filteredFiles, members, folderSizeMap]) const rows: ResourceRow[] = useMemo(() => { if (!listRename.editingId) return baseRows @@ -1644,9 +1670,7 @@ export function Files() { ? `No files match "${debouncedSearchTerm}"` : hasActiveFilters ? 'No files match the active filters' - : currentFolderId - ? 'This folder is empty' - : 'No files yet' + : undefined const filterContent = useMemo(() => { const typeDisplayLabel = diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 59dee5bbd92..d6aede43d85 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -30,7 +30,11 @@ import type { SelectableConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' -import { Resource, ResourceHeader } from '@/app/workspace/[workspaceId]/components' +import { + EMPTY_CELL_PLACEHOLDER, + Resource, + ResourceHeader, +} from '@/app/workspace/[workspaceId]/components' import { ChunkContextMenu, ChunkEditor, @@ -127,9 +131,9 @@ function truncateContent(content: string, maxLength = 150, searchQuery = ''): st const CHUNK_COLUMNS: ResourceColumn[] = [ { id: 'content', header: 'Content' }, - { id: 'index', header: 'Index', widthPx: 100 }, - { id: 'tokens', header: 'Tokens', widthPx: 100 }, - { id: 'status', header: 'Status', widthPx: 120 }, + { id: 'index', header: 'Index', widthMultiplier: 0.6 }, + { id: 'tokens', header: 'Tokens', widthMultiplier: 0.6 }, + { id: 'status', header: 'Status', widthMultiplier: 0.75 }, ] export function Document({ @@ -874,9 +878,9 @@ export function Document({
), }, - index: { label: '—' }, - tokens: { label: '—' }, - status: { label: '—' }, + index: { label: EMPTY_CELL_PLACEHOLDER }, + tokens: { label: EMPTY_CELL_PLACEHOLDER }, + status: { label: EMPTY_CELL_PLACEHOLDER }, }, }, ] diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 26489ab687d..65f71d7f516 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -85,13 +85,13 @@ const logger = createLogger('KnowledgeBase') const DOCUMENTS_PER_PAGE = 50 const DOCUMENT_COLUMNS: ResourceColumn[] = [ - { id: 'name', header: 'Name' }, - { id: 'size', header: 'Size', widthPx: 90 }, - { id: 'tokens', header: 'Tokens', widthPx: 90 }, - { id: 'chunks', header: 'Chunks', widthPx: 90 }, - { id: 'uploaded', header: 'Uploaded', widthPx: 140 }, - { id: 'status', header: 'Status', widthPx: 110 }, - { id: 'tags', header: 'Tags', widthPx: 160 }, + { id: 'name', header: 'Name', widthMultiplier: 0.8 }, + { id: 'size', header: 'Size', widthMultiplier: 0.75 }, + { id: 'tokens', header: 'Tokens', widthMultiplier: 0.75 }, + { id: 'chunks', header: 'Chunks', widthMultiplier: 0.75 }, + { id: 'uploaded', header: 'Uploaded' }, + { id: 'status', header: 'Status', widthMultiplier: 0.75 }, + { id: 'tags', header: 'Tags' }, ] interface KnowledgeBaseProps { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 32e4d9e7451..d5a4bc18009 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -16,7 +16,12 @@ import type { SearchConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' -import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components' +import { + EMPTY_CELL_PLACEHOLDER, + ownerCell, + Resource, + timeCell, +} from '@/app/workspace/[workspaceId]/components' import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { CreateBaseModal, @@ -43,19 +48,19 @@ interface KnowledgeBaseWithDocCount extends KnowledgeBaseData { const COLUMNS: ResourceColumn[] = [ { id: 'name', header: 'Name' }, - { id: 'documents', header: 'Documents', widthPx: 110 }, - { id: 'tokens', header: 'Tokens', widthPx: 90 }, - { id: 'connectors', header: 'Connectors', widthPx: 130 }, - { id: 'created', header: 'Created', widthPx: 140 }, - { id: 'owner', header: 'Owner', widthPx: 150 }, - { id: 'updated', header: 'Last Updated', widthPx: 140 }, + { id: 'documents', header: 'Documents', widthMultiplier: 0.6 }, + { id: 'tokens', header: 'Tokens', widthMultiplier: 0.6 }, + { id: 'connectors', header: 'Connectors', widthMultiplier: 0.7 }, + { id: 'created', header: 'Created' }, + { id: 'owner', header: 'Owner' }, + { id: 'updated', header: 'Last Updated' }, ] const DATABASE_ICON = function connectorCell(connectorTypes?: string[]): ResourceCell { if (!connectorTypes || connectorTypes.length === 0) { - return { label: '—' } + return { label: EMPTY_CELL_PLACEHOLDER } } const entries = connectorTypes @@ -64,7 +69,7 @@ function connectorCell(connectorTypes?: string[]): ResourceCell { Boolean(e.def?.icon) ) - if (entries.length === 0) return { label: '—' } + if (entries.length === 0) return { label: EMPTY_CELL_PLACEHOLDER } return { content: ( diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 20413838aec..7ddf9eec8f2 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -106,11 +106,11 @@ const REFRESH_SPINNER_DURATION_MS = 1000 as const const LOG_COLUMNS: ResourceColumn[] = [ { id: 'workflow', header: 'Workflow' }, - { id: 'date', header: 'Date', widthPx: 160 }, - { id: 'status', header: 'Status', widthPx: 120 }, - { id: 'cost', header: 'Cost', widthPx: 100 }, - { id: 'trigger', header: 'Trigger', widthPx: 140 }, - { id: 'duration', header: 'Duration', widthPx: 110 }, + { id: 'date', header: 'Date' }, + { id: 'status', header: 'Status' }, + { id: 'cost', header: 'Cost' }, + { id: 'trigger', header: 'Trigger' }, + { id: 'duration', header: 'Duration' }, ] interface LogSelectionState { diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx index 06182ff41d5..f74b92e3152 100644 --- a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx @@ -22,7 +22,11 @@ import type { ResourceRow, SortConfig, } from '@/app/workspace/[workspaceId]/components' -import { Resource, timeCell } from '@/app/workspace/[workspaceId]/components' +import { + EMPTY_CELL_PLACEHOLDER, + Resource, + timeCell, +} from '@/app/workspace/[workspaceId]/components' import { ScheduleModal } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/create-schedule-modal' import { ScheduleContextMenu } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-context-menu' import { ScheduleListContextMenu } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-list-context-menu' @@ -44,14 +48,14 @@ function getScheduleDescription(s: WorkspaceScheduleData) { const timing = parseCronToHumanReadable(s.cronExpression, s.timezone) return `Recurring, ${timing.charAt(0).toLowerCase()}${timing.slice(1)}` } - return '- - -' + return EMPTY_CELL_PLACEHOLDER } const COLUMNS: ResourceColumn[] = [ { id: 'task', header: 'Task' }, - { id: 'schedule', header: 'Schedule', widthMultiplier: 1.5 }, - { id: 'nextRun', header: 'Next Run', widthPx: 160 }, - { id: 'lastRun', header: 'Last Run', widthPx: 160 }, + { id: 'schedule', header: 'Schedule' }, + { id: 'nextRun', header: 'Next Run' }, + { id: 'lastRun', header: 'Last Run' }, ] export function ScheduledTasks() { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 6bd2a04d796..5cf881a2f4b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -49,11 +49,11 @@ const logger = createLogger('Tables') const COLUMNS: ResourceColumn[] = [ { id: 'name', header: 'Name' }, - { id: 'columns', header: 'Columns', widthPx: 110 }, - { id: 'rows', header: 'Rows', widthPx: 100 }, - { id: 'created', header: 'Created', widthPx: 150 }, - { id: 'owner', header: 'Owner', widthPx: 160 }, - { id: 'updated', header: 'Last Updated', widthPx: 150 }, + { id: 'columns', header: 'Columns' }, + { id: 'rows', header: 'Rows' }, + { id: 'created', header: 'Created' }, + { id: 'owner', header: 'Owner' }, + { id: 'updated', header: 'Last Updated' }, ] export function Tables() { diff --git a/apps/sim/blocks/blocks/findymail.ts b/apps/sim/blocks/blocks/findymail.ts index d5f11bc7431..4f8bea4911d 100644 --- a/apps/sim/blocks/blocks/findymail.ts +++ b/apps/sim/blocks/blocks/findymail.ts @@ -37,7 +37,7 @@ export const FindymailBlock: BlockConfig = { }, // Find Email From Name { - id: 'name', + id: 'fn_name', title: 'Full Name', type: 'short-input', required: true, @@ -45,7 +45,7 @@ export const FindymailBlock: BlockConfig = { condition: { field: 'operation', value: 'findymail_find_email_from_name' }, }, { - id: 'domain', + id: 'fn_domain', title: 'Company Domain or Name', type: 'short-input', required: true, @@ -54,7 +54,7 @@ export const FindymailBlock: BlockConfig = { }, // Find Email From LinkedIn { - id: 'linkedin_url', + id: 'fefl_linkedin_url', title: 'LinkedIn URL', type: 'short-input', required: true, @@ -63,7 +63,7 @@ export const FindymailBlock: BlockConfig = { }, // Find Emails By Domain { - id: 'domain', + id: 'fed_domain', title: 'Domain', type: 'short-input', required: true, @@ -77,10 +77,16 @@ export const FindymailBlock: BlockConfig = { required: true, placeholder: '["CEO", "Founder"]', condition: { field: 'operation', value: 'findymail_find_emails_by_domain' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a JSON array of job roles/titles to target at the company (max 3). Return ONLY the JSON array - no explanations, no extra text.', + placeholder: 'e.g. CEO, Founder, CTO', + }, }, // Verify Email { - id: 'email', + id: 've_email', title: 'Email Address', type: 'short-input', required: true, @@ -89,7 +95,7 @@ export const FindymailBlock: BlockConfig = { }, // Reverse Email Lookup { - id: 'email', + id: 'rel_email', title: 'Email Address', type: 'short-input', required: true, @@ -99,27 +105,32 @@ export const FindymailBlock: BlockConfig = { { id: 'with_profile', title: 'Return Full Profile', - type: 'switch', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', condition: { field: 'operation', value: 'findymail_reverse_email_lookup' }, mode: 'advanced', }, // Get Company Info — provide at least one of LinkedIn URL, domain, or name { - id: 'linkedin_url', + id: 'gc_linkedin_url', title: 'Company LinkedIn URL', type: 'short-input', placeholder: 'LinkedIn URL (at least one of URL/domain/name required)', condition: { field: 'operation', value: 'findymail_get_company' }, }, { - id: 'domain', + id: 'gc_domain', title: 'Company Domain', type: 'short-input', placeholder: 'stripe.com (at least one of URL/domain/name required)', condition: { field: 'operation', value: 'findymail_get_company' }, }, { - id: 'name', + id: 'gc_name', title: 'Company Name', type: 'short-input', placeholder: 'Stripe (at least one of URL/domain/name required)', @@ -141,6 +152,12 @@ export const FindymailBlock: BlockConfig = { required: true, placeholder: '["Software Engineer", "CEO"]', condition: { field: 'operation', value: 'findymail_find_employees' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a JSON array of job titles to search for at the company (max 10). Return ONLY the JSON array - no explanations, no extra text.', + placeholder: 'e.g. Software Engineer, CEO, Product Manager', + }, }, { id: 'count', @@ -152,7 +169,7 @@ export const FindymailBlock: BlockConfig = { }, // Find Phone { - id: 'linkedin_url', + id: 'fp_linkedin_url', title: 'LinkedIn URL', type: 'short-input', required: true, @@ -167,10 +184,16 @@ export const FindymailBlock: BlockConfig = { required: true, placeholder: 'React', condition: { field: 'operation', value: 'findymail_search_technologies' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a technology search term to look up in the Findymail technology catalog. Return ONLY the search term - no explanations, no extra text.', + placeholder: 'e.g. React, Stripe, Salesforce', + }, }, // Lookup Technologies By Domain { - id: 'domain', + id: 'lt_domain', title: 'Company Domain', type: 'short-input', required: true, @@ -184,6 +207,12 @@ export const FindymailBlock: BlockConfig = { placeholder: '["React", "TypeScript"]', condition: { field: 'operation', value: 'findymail_lookup_technologies' }, mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate a JSON array of technology names to filter by (case-insensitive). Return ONLY the JSON array - no explanations, no extra text.', + placeholder: 'e.g. React, TypeScript, Node.js', + }, }, // API Key { @@ -230,35 +259,58 @@ export const FindymailBlock: BlockConfig = { }, params: (params) => { const { operation: _operation, ...rest } = params + + // Map unique subBlock IDs back to tool param names + const idToParam: Record = { + fn_name: 'name', + fn_domain: 'domain', + fefl_linkedin_url: 'linkedin_url', + fed_domain: 'domain', + ve_email: 'email', + rel_email: 'email', + gc_linkedin_url: 'linkedin_url', + gc_domain: 'domain', + gc_name: 'name', + fp_linkedin_url: 'linkedin_url', + lt_domain: 'domain', + } + const result: Record = {} for (const [key, value] of Object.entries(rest)) { if (value === undefined || value === null || value === '') continue - if (key === 'count') { + const mappedKey = idToParam[key] ?? key + if (mappedKey === 'count') { const n = Number(value) - if (!Number.isNaN(n)) result[key] = n - } else if (key === 'roles' || key === 'job_titles' || key === 'technologies') { + if (!Number.isNaN(n)) result[mappedKey] = n + } else if (mappedKey === 'with_profile') { + result[mappedKey] = value === true || value === 'true' + } else if ( + mappedKey === 'roles' || + mappedKey === 'job_titles' || + mappedKey === 'technologies' + ) { if (Array.isArray(value)) { - result[key] = value + result[mappedKey] = value } else if (typeof value === 'string') { const trimmed = value.trim() if (trimmed.startsWith('[')) { try { const parsed = JSON.parse(trimmed) if (Array.isArray(parsed)) { - result[key] = parsed + result[mappedKey] = parsed continue } } catch { // fall through to comma-split } } - result[key] = trimmed + result[mappedKey] = trimmed .split(',') .map((s) => s.trim()) .filter(Boolean) } } else { - result[key] = value + result[mappedKey] = value } } return result @@ -268,23 +320,30 @@ export const FindymailBlock: BlockConfig = { inputs: { operation: { type: 'string', description: 'Operation to perform' }, apiKey: { type: 'string', description: 'Findymail API key' }, - email: { type: 'string', description: 'Email address' }, - name: { type: 'string', description: 'Full name or company name' }, - domain: { type: 'string', description: 'Company domain' }, - linkedin_url: { type: 'string', description: 'LinkedIn profile or company URL' }, + fn_name: { type: 'string', description: 'Full name (find email from name)' }, + fn_domain: { type: 'string', description: 'Company domain or name (find email from name)' }, + fefl_linkedin_url: { type: 'string', description: 'LinkedIn URL (find email from LinkedIn)' }, + fed_domain: { type: 'string', description: 'Company domain (find emails by domain)' }, roles: { type: 'array', description: 'Target roles (max 3)' }, + ve_email: { type: 'string', description: 'Email address to verify' }, + rel_email: { type: 'string', description: 'Email address for reverse lookup' }, with_profile: { type: 'boolean', description: 'Return full profile data on reverse lookup' }, + gc_linkedin_url: { type: 'string', description: 'Company LinkedIn URL (get company info)' }, + gc_domain: { type: 'string', description: 'Company domain (get company info)' }, + gc_name: { type: 'string', description: 'Company name (get company info)' }, website: { type: 'string', description: 'Company website for employee search' }, job_titles: { type: 'array', description: 'Target job titles (max 10)' }, count: { type: 'number', description: 'Number of contacts to return (max 5)' }, + fp_linkedin_url: { type: 'string', description: 'LinkedIn URL (find phone)' }, q: { type: 'string', description: 'Technology search query' }, + lt_domain: { type: 'string', description: 'Company domain (lookup technologies)' }, technologies: { type: 'array', description: 'Technology names to filter by' }, }, outputs: { // Verify Email verified: { type: 'boolean', description: 'Whether the email is deliverable' }, provider: { type: 'string', description: 'Email service provider' }, - // Find / Reverse / Company + // Find Email / LinkedIn contact: { type: 'json', description: 'Contact found (name, email, domain)', @@ -293,6 +352,7 @@ export const FindymailBlock: BlockConfig = { type: 'array', description: 'Contacts found at the domain (name, email, domain)', }, + // Reverse Email Lookup linkedin_url: { type: 'string', description: 'LinkedIn URL' }, fullName: { type: 'string', description: 'Full name from LinkedIn profile' }, username: { type: 'string', description: 'LinkedIn username' }, @@ -302,10 +362,16 @@ export const FindymailBlock: BlockConfig = { city: { type: 'string', description: 'City' }, region: { type: 'string', description: 'Region/state' }, country: { type: 'string', description: 'Country' }, - companyLinkedinUrl: { type: 'string', description: 'Current company LinkedIn URL' }, + companyLinkedinUrl: { + type: 'string', + description: 'Current company LinkedIn URL', + }, companyName: { type: 'string', description: 'Current company name' }, companyWebsite: { type: 'string', description: 'Current company website' }, - isPremium: { type: 'boolean', description: 'Whether the profile has LinkedIn Premium' }, + isPremium: { + type: 'boolean', + description: 'Whether the profile has LinkedIn Premium', + }, isOpenProfile: { type: 'boolean', description: 'Whether the profile is open' }, skills: { type: 'array', description: 'Profile skills' }, jobs: { type: 'array', description: 'Job history entries' }, @@ -318,9 +384,12 @@ export const FindymailBlock: BlockConfig = { description: 'Certifications (name, issuingOrganization, issueDate, expirationDate)', }, // Get Company - name: { type: 'string', description: 'Company name (get-company)' }, + name: { type: 'string', description: 'Company name' }, domain: { type: 'string', description: 'Company domain' }, - company_size: { type: 'string', description: 'Headcount range (e.g., 1001-5000)' }, + company_size: { + type: 'string', + description: 'Headcount range (e.g., 1001-5000)', + }, industry: { type: 'string', description: 'Industry classification' }, description: { type: 'string', description: 'Company description' }, // Find Employees @@ -329,8 +398,14 @@ export const FindymailBlock: BlockConfig = { description: 'Employees found (name, linkedinUrl, companyWebsite, companyName, jobTitle)', }, // Find Phone - phone: { type: 'string', description: 'Phone number in E.164 format (US only)' }, - line_type: { type: 'string', description: 'Phone line type (Mobile, Landline)' }, + phone: { + type: 'string', + description: 'Phone number in E.164 format (US only)', + }, + line_type: { + type: 'string', + description: 'Phone line type (Mobile, Landline)', + }, // Technologies technologies: { type: 'array', @@ -338,8 +413,11 @@ export const FindymailBlock: BlockConfig = { }, // Get Credits credits: { type: 'number', description: 'Remaining finder credits' }, - verifier_credits: { type: 'number', description: 'Remaining verifier credits' }, - // Reverse / Verify shared + verifier_credits: { + type: 'number', + description: 'Remaining verifier credits', + }, + // Verify / Reverse shared email: { type: 'string', description: 'Email address' }, }, } diff --git a/apps/sim/blocks/blocks/prospeo.ts b/apps/sim/blocks/blocks/prospeo.ts index 823b1f04f24..2980efdb96a 100644 --- a/apps/sim/blocks/blocks/prospeo.ts +++ b/apps/sim/blocks/blocks/prospeo.ts @@ -70,21 +70,21 @@ export const ProspeoBlock: BlockConfig = { condition: { field: 'operation', value: 'prospeo_enrich_person' }, }, { - id: 'company_name', + id: 'ep_company_name', title: 'Company Name', type: 'short-input', placeholder: 'Intercom', condition: { field: 'operation', value: 'prospeo_enrich_person' }, }, { - id: 'company_website', + id: 'ep_company_website', title: 'Company Website', type: 'short-input', placeholder: 'intercom.com', condition: { field: 'operation', value: 'prospeo_enrich_person' }, }, { - id: 'company_linkedin_url', + id: 'ep_company_linkedin_url', title: 'Company LinkedIn URL', type: 'short-input', placeholder: 'https://www.linkedin.com/company/intercom', @@ -100,44 +100,56 @@ export const ProspeoBlock: BlockConfig = { mode: 'advanced', }, { - id: 'only_verified_email', + id: 'ep_only_verified_email', title: 'Only Verified Email', - type: 'switch', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], condition: { field: 'operation', value: 'prospeo_enrich_person' }, mode: 'advanced', }, { - id: 'enrich_mobile', + id: 'ep_enrich_mobile', title: 'Enrich Mobile', - type: 'switch', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], condition: { field: 'operation', value: 'prospeo_enrich_person' }, mode: 'advanced', }, { - id: 'only_verified_mobile', + id: 'ep_only_verified_mobile', title: 'Only Verified Mobile', - type: 'switch', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], condition: { field: 'operation', value: 'prospeo_enrich_person' }, mode: 'advanced', }, // Enrich Company { - id: 'company_website', + id: 'ec_company_website', title: 'Company Website', type: 'short-input', placeholder: 'intercom.com', condition: { field: 'operation', value: 'prospeo_enrich_company' }, }, { - id: 'company_linkedin_url', + id: 'ec_company_linkedin_url', title: 'Company LinkedIn URL', type: 'short-input', placeholder: 'https://www.linkedin.com/company/intercom', condition: { field: 'operation', value: 'prospeo_enrich_company' }, }, { - id: 'company_name', + id: 'ec_company_name', title: 'Company Name', type: 'short-input', placeholder: 'Intercom', @@ -155,7 +167,7 @@ export const ProspeoBlock: BlockConfig = { // Bulk Enrich Person { - id: 'data', + id: 'bep_data', title: 'Records', type: 'code', language: 'json', @@ -171,30 +183,42 @@ export const ProspeoBlock: BlockConfig = { }, }, { - id: 'only_verified_email', + id: 'bep_only_verified_email', title: 'Only Verified Email', - type: 'switch', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], condition: { field: 'operation', value: 'prospeo_bulk_enrich_person' }, mode: 'advanced', }, { - id: 'enrich_mobile', + id: 'bep_enrich_mobile', title: 'Enrich Mobile', - type: 'switch', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], condition: { field: 'operation', value: 'prospeo_bulk_enrich_person' }, mode: 'advanced', }, { - id: 'only_verified_mobile', + id: 'bep_only_verified_mobile', title: 'Only Verified Mobile', - type: 'switch', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], condition: { field: 'operation', value: 'prospeo_bulk_enrich_person' }, mode: 'advanced', }, // Bulk Enrich Company { - id: 'data', + id: 'bec_data', title: 'Records', type: 'code', language: 'json', @@ -212,7 +236,7 @@ export const ProspeoBlock: BlockConfig = { // Search Person { - id: 'filters', + id: 'sp_filters', title: 'Filters', type: 'code', language: 'json', @@ -228,7 +252,7 @@ export const ProspeoBlock: BlockConfig = { }, }, { - id: 'page', + id: 'sp_page', title: 'Page', type: 'short-input', placeholder: '1', @@ -238,7 +262,7 @@ export const ProspeoBlock: BlockConfig = { // Search Company { - id: 'filters', + id: 'sc_filters', title: 'Filters', type: 'code', language: 'json', @@ -254,7 +278,7 @@ export const ProspeoBlock: BlockConfig = { }, }, { - id: 'page', + id: 'sc_page', title: 'Page', type: 'short-input', placeholder: '1', @@ -323,23 +347,45 @@ export const ProspeoBlock: BlockConfig = { } }, params: (params) => { + const renames: Record = { + ep_company_name: 'company_name', + ep_company_website: 'company_website', + ep_company_linkedin_url: 'company_linkedin_url', + ep_only_verified_email: 'only_verified_email', + ep_enrich_mobile: 'enrich_mobile', + ep_only_verified_mobile: 'only_verified_mobile', + ec_company_website: 'company_website', + ec_company_linkedin_url: 'company_linkedin_url', + ec_company_name: 'company_name', + bep_data: 'data', + bep_only_verified_email: 'only_verified_email', + bep_enrich_mobile: 'enrich_mobile', + bep_only_verified_mobile: 'only_verified_mobile', + bec_data: 'data', + sp_filters: 'filters', + sp_page: 'page', + sc_filters: 'filters', + sc_page: 'page', + } const result: Record = {} for (const [key, value] of Object.entries(params)) { if (value === undefined || value === null || value === '') continue - if (key === 'page') { + if (key === 'operation') continue + const targetKey = renames[key] ?? key + if (targetKey === 'page') { const n = Number(value) - if (Number.isFinite(n)) result[key] = n + if (Number.isFinite(n)) result[targetKey] = n continue } if ( - key === 'only_verified_email' || - key === 'enrich_mobile' || - key === 'only_verified_mobile' + targetKey === 'only_verified_email' || + targetKey === 'enrich_mobile' || + targetKey === 'only_verified_mobile' ) { - result[key] = typeof value === 'string' ? value === 'true' : Boolean(value) + result[targetKey] = typeof value === 'string' ? value === 'true' : Boolean(value) continue } - result[key] = value + result[targetKey] = value } return result }, @@ -348,25 +394,55 @@ export const ProspeoBlock: BlockConfig = { inputs: { operation: { type: 'string', description: 'Operation to perform' }, apiKey: { type: 'string', description: 'Prospeo API key' }, - // Enrich Person / Company match keys + // Enrich Person match keys first_name: { type: 'string', description: 'First name' }, last_name: { type: 'string', description: 'Last name' }, full_name: { type: 'string', description: 'Full name' }, linkedin_url: { type: 'string', description: 'Person LinkedIn URL' }, email: { type: 'string', description: 'Work email' }, - company_name: { type: 'string', description: 'Company name' }, - company_website: { type: 'string', description: 'Company website' }, - company_linkedin_url: { type: 'string', description: 'Company LinkedIn URL' }, - company_id: { type: 'string', description: 'Prospeo company_id' }, + ep_company_name: { type: 'string', description: 'Company name (enrich person)' }, + ep_company_website: { type: 'string', description: 'Company website (enrich person)' }, + ep_company_linkedin_url: { + type: 'string', + description: 'Company LinkedIn URL (enrich person)', + }, person_id: { type: 'string', description: 'Prospeo person_id' }, - only_verified_email: { type: 'boolean', description: 'Only verified emails' }, - enrich_mobile: { type: 'boolean', description: 'Reveal mobile numbers' }, - only_verified_mobile: { type: 'boolean', description: 'Only records with a mobile' }, - // Bulk - data: { type: 'json', description: 'Array of records to enrich' }, - // Search - filters: { type: 'json', description: 'Search filters configuration' }, - page: { type: 'number', description: 'Page number (defaults to 1)' }, + ep_only_verified_email: { type: 'string', description: 'Only verified emails (enrich person)' }, + ep_enrich_mobile: { type: 'string', description: 'Reveal mobile numbers (enrich person)' }, + ep_only_verified_mobile: { + type: 'string', + description: 'Only records with a mobile (enrich person)', + }, + // Enrich Company match keys + ec_company_website: { type: 'string', description: 'Company website (enrich company)' }, + ec_company_linkedin_url: { + type: 'string', + description: 'Company LinkedIn URL (enrich company)', + }, + ec_company_name: { type: 'string', description: 'Company name (enrich company)' }, + company_id: { type: 'string', description: 'Prospeo company_id' }, + // Bulk Person + bep_data: { type: 'json', description: 'Array of person records to enrich (bulk)' }, + bep_only_verified_email: { + type: 'string', + description: 'Only verified emails (bulk enrich person)', + }, + bep_enrich_mobile: { + type: 'string', + description: 'Reveal mobile numbers (bulk enrich person)', + }, + bep_only_verified_mobile: { + type: 'string', + description: 'Only records with a mobile (bulk enrich person)', + }, + // Bulk Company + bec_data: { type: 'json', description: 'Array of company records to enrich (bulk)' }, + // Search Person + sp_filters: { type: 'json', description: 'Search person filters configuration' }, + sp_page: { type: 'string', description: 'Search person page number (defaults to 1)' }, + // Search Company + sc_filters: { type: 'json', description: 'Search company filters configuration' }, + sc_page: { type: 'string', description: 'Search company page number (defaults to 1)' }, // Suggestions location_search: { type: 'string', description: 'Location search query' }, job_title_search: { type: 'string', description: 'Job title search query' }, diff --git a/apps/sim/components/emcn/components/toast/toast.tsx b/apps/sim/components/emcn/components/toast/toast.tsx index 11364ff3a7f..f784c4b9cce 100644 --- a/apps/sim/components/emcn/components/toast/toast.tsx +++ b/apps/sim/components/emcn/components/toast/toast.tsx @@ -1,6 +1,7 @@ 'use client' import { + type CSSProperties, createContext, type ReactNode, useCallback, @@ -17,7 +18,8 @@ import { cn } from '@/lib/core/utils/cn' const AUTO_DISMISS_MS = 5000 const EXIT_ANIMATION_MS = 200 -const MAX_VISIBLE = 20 +const MAX_VISIBLE = 4 +const STACK_OFFSET_PX = 3 const RING_RADIUS = 5.5 const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS @@ -127,7 +129,15 @@ function CountdownRing({ duration }: { duration: number }) { ) } -function ToastItem({ toast: t, onDismiss }: { toast: ToastData; onDismiss: (id: string) => void }) { +function ToastItem({ + toast: t, + stackOffset, + onDismiss, +}: { + toast: ToastData + stackOffset: number + onDismiss: (id: string) => void +}) { const [exiting, setExiting] = useState(false) const pausedRef = useRef(false) const timerRef = useRef>(undefined) @@ -168,8 +178,9 @@ function ToastItem({ toast: t, onDismiss }: { toast: ToastData; onDismiss: (id:
- {toasts.map((t) => ( - - ))} + {toasts.map((t, index) => { + const depth = toasts.length - index - 1 + + return ( + + ) + })}
, document.body )} diff --git a/apps/sim/tools/findymail/index.ts b/apps/sim/tools/findymail/index.ts index 759ee4c5f0e..ddf4b5db138 100644 --- a/apps/sim/tools/findymail/index.ts +++ b/apps/sim/tools/findymail/index.ts @@ -1,3 +1,5 @@ +export * from './types' + import { findEmailFromLinkedInTool } from '@/tools/findymail/find_email_from_linkedin' import { findEmailFromNameTool } from '@/tools/findymail/find_email_from_name' import { findEmailsByDomainTool } from '@/tools/findymail/find_emails_by_domain' diff --git a/apps/sim/tools/prospeo/bulk_enrich_person.ts b/apps/sim/tools/prospeo/bulk_enrich_person.ts index 272b15a72b4..b6d095e9526 100644 --- a/apps/sim/tools/prospeo/bulk_enrich_person.ts +++ b/apps/sim/tools/prospeo/bulk_enrich_person.ts @@ -32,19 +32,19 @@ export const bulkEnrichPersonTool: ToolConfig< only_verified_email: { type: 'boolean', required: false, - visibility: 'user-only', + visibility: 'user-or-llm', description: 'Only return records with a verified email', }, enrich_mobile: { type: 'boolean', required: false, - visibility: 'user-only', + visibility: 'user-or-llm', description: 'Reveal mobile numbers (10 credits per match; email included)', }, only_verified_mobile: { type: 'boolean', required: false, - visibility: 'user-only', + visibility: 'user-or-llm', description: 'Only return records that have a mobile (implies enrich_mobile)', }, }, diff --git a/apps/sim/tools/prospeo/enrich_person.ts b/apps/sim/tools/prospeo/enrich_person.ts index 2187383e059..44c72a26007 100644 --- a/apps/sim/tools/prospeo/enrich_person.ts +++ b/apps/sim/tools/prospeo/enrich_person.ts @@ -76,19 +76,19 @@ export const enrichPersonTool: ToolConfig { + if (!params.location_search && !params.job_title_search) { + throw new Error( + 'Provide exactly one of location_search or job_title_search (minimum 2 characters).' + ) + } + if (params.location_search && params.job_title_search) { + throw new Error( + 'location_search and job_title_search are mutually exclusive — provide only one.' + ) + } const body: Record = {} if (params.location_search) body.location_search = params.location_search if (params.job_title_search) body.job_title_search = params.job_title_search @@ -61,8 +71,8 @@ export const searchSuggestionsTool: ToolConfig< return { success: true, output: { - location_suggestions: data.location_suggestions ?? null, - job_title_suggestions: data.job_title_suggestions ?? null, + location_suggestions: data.location_suggestions ?? [], + job_title_suggestions: data.job_title_suggestions ?? [], }, } }, @@ -71,7 +81,7 @@ export const searchSuggestionsTool: ToolConfig< location_suggestions: { type: 'array', description: - 'Location suggestions when using location_search (null when searching job titles)', + 'Location suggestions when using location_search (empty when searching job titles)', optional: true, items: { type: 'object', @@ -87,7 +97,7 @@ export const searchSuggestionsTool: ToolConfig< job_title_suggestions: { type: 'array', description: - 'Up to 25 job title suggestions ordered by popularity when using job_title_search (null when searching locations)', + 'Up to 25 job title suggestions ordered by popularity when using job_title_search (empty when searching locations)', optional: true, items: { type: 'string' }, }, diff --git a/apps/sim/tools/prospeo/types.ts b/apps/sim/tools/prospeo/types.ts index 391006b4678..c44ec03f83c 100644 --- a/apps/sim/tools/prospeo/types.ts +++ b/apps/sim/tools/prospeo/types.ts @@ -5,7 +5,6 @@ export interface ProspeoBaseParams { } export interface ProspeoPersonData { - identifier?: string first_name?: string last_name?: string full_name?: string @@ -18,7 +17,6 @@ export interface ProspeoPersonData { } export interface ProspeoCompanyData { - identifier?: string company_name?: string company_website?: string company_linkedin_url?: string @@ -35,6 +33,7 @@ export interface ProspeoPaginationOutput { export const PROSPEO_PAGINATION_OUTPUT: OutputProperty = { type: 'object', description: 'Pagination details', + optional: true, properties: { current_page: { type: 'number', description: 'Current page number' }, per_page: { type: 'number', description: 'Results per page' }, @@ -84,7 +83,7 @@ export interface ProspeoEnrichCompanyResponse extends ToolResponse { /** Bulk Enrich Person */ export interface ProspeoBulkEnrichPersonParams extends ProspeoBaseParams { - data: ProspeoPersonData[] + data: Array only_verified_email?: boolean enrich_mobile?: boolean only_verified_mobile?: boolean @@ -105,7 +104,7 @@ export interface ProspeoBulkEnrichPersonResponse extends ToolResponse { /** Bulk Enrich Company */ export interface ProspeoBulkEnrichCompanyParams extends ProspeoBaseParams { - data: ProspeoCompanyData[] + data: Array } export interface ProspeoBulkEnrichCompanyResponse extends ToolResponse { @@ -161,8 +160,8 @@ export interface ProspeoSearchSuggestionsParams extends ProspeoBaseParams { export interface ProspeoSearchSuggestionsResponse extends ToolResponse { output: { - location_suggestions: Array<{ name: string; type: string }> | null - job_title_suggestions: string[] | null + location_suggestions: Array<{ name: string; type: string }> + job_title_suggestions: string[] } } From a0d9e4dc90791caba23ec4d8bd147bd179e9cec4 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 18 May 2026 19:26:51 -0700 Subject: [PATCH 09/10] fix(tables): type-aware SQL casts for range filters on date columns (#4657) * fix(tables): type-aware SQL casts for range filters on date columns * improvement(table): tighten filter-cast types & workspace guards - Drop redundant tableId/workspaceId from BulkUpdateData and BulkDeleteData; service uses table.id / table.workspaceId so column metadata and DB scope can't drift apart. - Add missing workspace-id guards to copilot user-table cases (insert_row, batch_insert_rows, update_row, batch_update_rows, rename); collapse duplicated rename check. - Add service-level integration tests that buildFilterClause/buildSortClause receive table.schema.columns from queryRows, updateRowsByFilter, deleteRowsByFilter. * improvement(table): cast jsonb date filters/sorts to timestamptz ::timestamp strips timezone offsets from ISO strings, making comparisons depend on the server TimeZone setting. ::timestamptz preserves the offset so chronological comparisons are correct regardless of server config. * improvement(table): correct JSDoc examples for required columns arg * improvement(table): validate range operator value types at SQL builder --- .../app/api/table/[tableId]/export/route.ts | 3 +- .../sim/app/api/table/[tableId]/rows/route.ts | 16 +- .../app/api/v1/tables/[tableId]/rows/route.ts | 16 +- .../tools/handlers/function-execute.ts | 4 +- .../copilot/tools/server/table/user-table.ts | 35 +- .../visualization/generate-visualization.ts | 4 +- .../service-filter-threading.test.ts | 123 +++++ apps/sim/lib/table/__tests__/sql.test.ts | 517 +++++++++++------- apps/sim/lib/table/service.ts | 49 +- apps/sim/lib/table/sql.ts | 181 ++++-- apps/sim/lib/table/types.ts | 12 +- 11 files changed, 644 insertions(+), 316 deletions(-) create mode 100644 apps/sim/lib/table/__tests__/service-filter-threading.test.ts diff --git a/apps/sim/app/api/table/[tableId]/export/route.ts b/apps/sim/app/api/table/[tableId]/export/route.ts index a2e59b42f83..8f9fa34b807 100644 --- a/apps/sim/app/api/table/[tableId]/export/route.ts +++ b/apps/sim/app/api/table/[tableId]/export/route.ts @@ -62,8 +62,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou let firstJsonRow = true while (true) { const result = await queryRows( - tableId, - table.workspaceId, + table, { limit: EXPORT_BATCH_SIZE, offset, includeTotal: false }, requestId ) diff --git a/apps/sim/app/api/table/[tableId]/rows/route.ts b/apps/sim/app/api/table/[tableId]/rows/route.ts index 9b0076a127d..414ac57d41a 100644 --- a/apps/sim/app/api/table/[tableId]/rows/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/route.ts @@ -266,8 +266,14 @@ export const GET = withRouteHandler( eq(userTableRows.workspaceId, validated.workspaceId), ] + const schema = table.schema as TableSchema + if (validated.filter) { - const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME) + const filterClause = buildFilterClause( + validated.filter as Filter, + USER_TABLE_ROWS_SQL_NAME, + schema.columns + ) if (filterClause) { baseConditions.push(filterClause) } @@ -286,7 +292,6 @@ export const GET = withRouteHandler( .where(and(...baseConditions)) if (validated.sort) { - const schema = table.schema as TableSchema const sortClause = buildSortClause(validated.sort, USER_TABLE_ROWS_SQL_NAME, schema.columns) if (sortClause) { query = query.orderBy(sortClause) as typeof query @@ -388,14 +393,12 @@ export const PUT = withRouteHandler( } const result = await updateRowsByFilter( + table, { - tableId, filter: validated.filter as Filter, data: validated.data as RowData, limit: validated.limit, - workspaceId: validated.workspaceId, }, - table, requestId ) @@ -503,11 +506,10 @@ export const DELETE = withRouteHandler( } const result = await deleteRowsByFilter( + table, { - tableId, filter: validated.filter as Filter, limit: validated.limit, - workspaceId: validated.workspaceId, }, requestId ) diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts index d4d9c448837..e736a859eaa 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -158,8 +158,14 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR eq(userTableRows.workspaceId, validated.workspaceId), ] + const schema = table.schema as TableSchema + if (validated.filter) { - const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME) + const filterClause = buildFilterClause( + validated.filter as Filter, + USER_TABLE_ROWS_SQL_NAME, + schema.columns + ) if (filterClause) { baseConditions.push(filterClause) } @@ -177,7 +183,6 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR .where(and(...baseConditions)) if (validated.sort) { - const schema = table.schema as TableSchema const sortClause = buildSortClause(validated.sort, USER_TABLE_ROWS_SQL_NAME, schema.columns) if (sortClause) { query = query.orderBy(sortClause) as typeof query @@ -378,14 +383,12 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR } const result = await updateRowsByFilter( + table, { - tableId, filter: validated.filter as Filter, data: validated.data as RowData, limit: validated.limit, - workspaceId: validated.workspaceId, }, - table, requestId ) @@ -484,11 +487,10 @@ export const DELETE = withRouteHandler( } const result = await deleteRowsByFilter( + table, { - tableId, filter: validated.filter as Filter, limit: validated.limit, - workspaceId: validated.workspaceId, }, requestId ) diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 29f53924610..0968b3ffa86 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -62,11 +62,11 @@ async function resolveInputFiles( for (const tableId of inputTables) { if (typeof tableId !== 'string') continue const table = await getTableById(tableId) - if (!table) { + if (!table || table.workspaceId !== workspaceId) { logger.warn('Input table not found', { tableId }) continue } - const rows = await queryRows(tableId, workspaceId, {}, 'copilot-fn-exec') + const rows = await queryRows(table, {}, 'copilot-fn-exec') if (!rows.rows?.length) continue const allKeys = new Set() diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index e6464e87afd..1baee52e9d0 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -367,7 +367,7 @@ export const userTableServerTool: BaseServerTool } const table = await getTableById(args.tableId) - if (!table) { + if (!table || table.workspaceId !== workspaceId) { return { success: false, message: `Table not found: ${args.tableId}` } } @@ -418,7 +418,7 @@ export const userTableServerTool: BaseServerTool } const table = await getTableById(args.tableId) - if (!table) { + if (!table || table.workspaceId !== workspaceId) { return { success: false, message: `Table not found: ${args.tableId}` } } @@ -474,10 +474,14 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } + const table = await getTableById(args.tableId) + if (!table || table.workspaceId !== workspaceId) { + return { success: false, message: `Table not found: ${args.tableId}` } + } + const requestId = generateId().slice(0, 8) const result = await queryRows( - args.tableId, - workspaceId, + table, { filter: args.filter, sort: args.sort, @@ -509,7 +513,7 @@ export const userTableServerTool: BaseServerTool } const table = await getTableById(args.tableId) - if (!table) { + if (!table || table.workspaceId !== workspaceId) { return { success: false, message: `Table not found: ${args.tableId}` } } @@ -569,21 +573,19 @@ export const userTableServerTool: BaseServerTool } const table = await getTableById(args.tableId) - if (!table) { + if (!table || table.workspaceId !== workspaceId) { return { success: false, message: `Table not found: ${args.tableId}` } } const requestId = generateId().slice(0, 8) assertNotAborted() const result = await updateRowsByFilter( + table, { - tableId: args.tableId, filter: args.filter, data: args.data, limit: args.limit, - workspaceId, }, - table, requestId ) @@ -605,14 +607,18 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } + const table = await getTableById(args.tableId) + if (!table || table.workspaceId !== workspaceId) { + return { success: false, message: `Table not found: ${args.tableId}` } + } + const requestId = generateId().slice(0, 8) assertNotAborted() const result = await deleteRowsByFilter( + table, { - tableId: args.tableId, filter: args.filter, limit: args.limit, - workspaceId, }, requestId ) @@ -664,7 +670,7 @@ export const userTableServerTool: BaseServerTool } const table = await getTableById(args.tableId) - if (!table) { + if (!table || table.workspaceId !== workspaceId) { return { success: false, message: `Table not found: ${args.tableId}` } } @@ -1089,12 +1095,9 @@ export const userTableServerTool: BaseServerTool } const table = await getTableById(args.tableId) - if (!table) { + if (!table || table.workspaceId !== workspaceId) { return { success: false, message: `Table not found: ${args.tableId}` } } - if (table.workspaceId !== workspaceId) { - return { success: false, message: 'Table not found' } - } const requestId = generateId().slice(0, 8) assertNotAborted() diff --git a/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts b/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts index e642c244249..629622a1cf3 100644 --- a/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts +++ b/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts @@ -118,11 +118,11 @@ async function collectSandboxFiles( if (inputTables?.length) { for (const tableId of inputTables) { const table = await getTableById(tableId) - if (!table) { + if (!table || table.workspaceId !== workspaceId) { logger.warn('Sandbox input table not found', { tableId }) continue } - const { rows } = await queryRows(tableId, workspaceId, { limit: 10000 }, 'sandbox-input') + const { rows } = await queryRows(table, { limit: 10000 }, 'sandbox-input') const schema = table.schema as { columns: Array<{ name: string; type?: string }> } const cols = schema.columns.map((c) => c.name) const typeComment = `# types: ${schema.columns.map((c) => `${c.name}=${c.type || 'string'}`).join(', ')}` diff --git a/apps/sim/lib/table/__tests__/service-filter-threading.test.ts b/apps/sim/lib/table/__tests__/service-filter-threading.test.ts new file mode 100644 index 00000000000..9174d05c6b6 --- /dev/null +++ b/apps/sim/lib/table/__tests__/service-filter-threading.test.ts @@ -0,0 +1,123 @@ +/** + * @vitest-environment node + * + * Integration test asserting that `table.schema.columns` is forwarded to + * `buildFilterClause` from each service function that filters rows. This + * guards the contract that type-aware JSONB casts (numeric for numbers, + * timestamp for dates) are always available at the SQL builder layer — the + * latent bug that PR #4657 was originally fixing. + */ +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { sql } from 'drizzle-orm' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildFilterClause, buildSortClause } from '@/lib/table/sql' +import type { ColumnDefinition, TableDefinition } from '@/lib/table/types' + +vi.mock('@sim/db', () => dbChainMock) + +vi.mock('@/lib/table/sql', () => ({ + buildFilterClause: vi.fn(() => sql`true`), + buildSortClause: vi.fn(() => sql`true`), +})) + +vi.mock('@/lib/table/trigger', () => ({ + fireTableTrigger: vi.fn(), +})) + +vi.mock('@/lib/table/workflow-columns', () => ({ + assertValidSchema: vi.fn(), + scheduleRunsForRows: vi.fn(), + scheduleRunsForTable: vi.fn(), + stripGroupDeps: vi.fn(), +})) + +vi.mock('@/lib/table/validation', () => ({ + validateRowSize: vi.fn(() => ({ valid: true, errors: [] })), + validateRowAgainstSchema: vi.fn(() => ({ valid: true, errors: [] })), + validateTableName: vi.fn(() => ({ valid: true, errors: [] })), + validateTableSchema: vi.fn(() => ({ valid: true, errors: [] })), + getUniqueColumns: vi.fn(() => []), + checkUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })), + checkBatchUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })), +})) + +import { deleteRowsByFilter, queryRows, updateRowsByFilter } from '@/lib/table/service' + +const COLUMNS: ColumnDefinition[] = [ + { name: 'name', type: 'string' }, + { name: 'birthDate', type: 'date' }, + { name: 'score', type: 'number' }, +] + +const TABLE: TableDefinition = { + id: 'tbl-1', + name: 'People', + description: null, + schema: { columns: COLUMNS }, + metadata: null, + rowCount: 0, + maxRows: 1000, + workspaceId: 'ws-1', + createdBy: 'user-1', + archivedAt: null, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), +} + +describe('service filter threading', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('queryRows forwards table.schema.columns to buildFilterClause', async () => { + await queryRows( + TABLE, + { filter: { birthDate: { $gte: '2024-01-01' } }, includeTotal: false }, + 'req-1' + ).catch(() => {}) + + expect(buildFilterClause).toHaveBeenCalledTimes(1) + expect(buildFilterClause).toHaveBeenCalledWith( + { birthDate: { $gte: '2024-01-01' } }, + expect.any(String), + COLUMNS + ) + }) + + it('queryRows forwards columns to buildSortClause as well', async () => { + await queryRows(TABLE, { sort: { birthDate: 'asc' }, includeTotal: false }, 'req-1').catch( + () => {} + ) + + expect(buildSortClause).toHaveBeenCalledWith({ birthDate: 'asc' }, expect.any(String), COLUMNS) + }) + + it('updateRowsByFilter forwards table.schema.columns to buildFilterClause', async () => { + dbChainMockFns.where.mockResolvedValueOnce([]) + await updateRowsByFilter( + TABLE, + { filter: { birthDate: { $lt: '2024-06-01' } }, data: { name: 'x' } }, + 'req-1' + ) + + expect(buildFilterClause).toHaveBeenCalledTimes(1) + expect(buildFilterClause).toHaveBeenCalledWith( + { birthDate: { $lt: '2024-06-01' } }, + expect.any(String), + COLUMNS + ) + }) + + it('deleteRowsByFilter forwards table.schema.columns to buildFilterClause', async () => { + dbChainMockFns.where.mockResolvedValueOnce([]) + await deleteRowsByFilter(TABLE, { filter: { score: { $gt: 90 } } }, 'req-1') + + expect(buildFilterClause).toHaveBeenCalledTimes(1) + expect(buildFilterClause).toHaveBeenCalledWith( + { score: { $gt: 90 } }, + expect.any(String), + COLUMNS + ) + }) +}) diff --git a/apps/sim/lib/table/__tests__/sql.test.ts b/apps/sim/lib/table/__tests__/sql.test.ts index 492e3e7fc8b..5258d91a06a 100644 --- a/apps/sim/lib/table/__tests__/sql.test.ts +++ b/apps/sim/lib/table/__tests__/sql.test.ts @@ -3,296 +3,393 @@ * * SQL Builder Unit Tests * - * Tests for the table SQL query builder utilities including filter and sort clause generation. + * Tests the table SQL query builder. Assertions inspect the generated SQL + * string so cast selection (numeric vs timestamptz) is verified end-to-end. + * + * Rendering: `drizzle-orm` is globally mocked in `vitest.setup.ts`. The mock + * represents tagged-template fragments as `{ strings, values }`, raw fragments + * as `{ rawSql }`, and joined fragments as `{ fragments, separator }`. The + * local `renderSql` helper walks that shape recursively so we can assert real + * substrings like `::timestamptz` against the generated SQL. */ import { describe, expect, it } from 'vitest' -import { buildFilterClause, buildSortClause } from '../sql' -import type { Filter } from '../types' +import { buildFilterClause, buildSortClause } from '@/lib/table/sql' +import type { ColumnDefinition, Filter, Sort } from '@/lib/table/types' + +type SqlNode = + | { strings: ArrayLike; values: unknown[] } + | { rawSql: string } + | { fragments: unknown[]; separator: unknown } + | string + | number + | boolean + | null + | undefined + +function isTemplateNode(n: unknown): n is { strings: ArrayLike; values: unknown[] } { + return ( + typeof n === 'object' && + n !== null && + 'strings' in n && + 'values' in n && + Array.isArray((n as { values: unknown[] }).values) + ) +} + +function isRawNode(n: unknown): n is { rawSql: string } { + return typeof n === 'object' && n !== null && 'rawSql' in n +} + +function isJoinNode(n: unknown): n is { fragments: unknown[]; separator: unknown } { + return ( + typeof n === 'object' && + n !== null && + 'fragments' in n && + Array.isArray((n as { fragments: unknown[] }).fragments) + ) +} + +/** Recursively render a mock SQL node into its generated SQL string. */ +function renderSql(node: SqlNode | unknown): string { + if (node == null) return String(node) + if (isRawNode(node)) return node.rawSql + if (isJoinNode(node)) { + const sep = isRawNode(node.separator) ? node.separator.rawSql : ', ' + return node.fragments.map(renderSql).join(sep) + } + if (isTemplateNode(node)) { + const parts: string[] = [] + for (let i = 0; i < node.strings.length; i++) { + parts.push(node.strings[i]) + if (i < node.values.length) { + parts.push(renderSql(node.values[i])) + } + } + return parts.join('') + } + if (typeof node === 'string') return `'${node}'` + return String(node) +} + +function render(node: unknown): string { + return renderSql(node) +} + +const TABLE = 'user_table_rows' +const NO_COLUMNS: ColumnDefinition[] = [] describe('SQL Builder', () => { describe('buildFilterClause', () => { - const tableName = 'user_table_rows' - - it('should return undefined for empty filter', () => { - const result = buildFilterClause({}, tableName) - expect(result).toBeUndefined() + it('returns undefined for empty filter', () => { + expect(buildFilterClause({}, TABLE, NO_COLUMNS)).toBeUndefined() }) - it('should handle simple equality filter', () => { - const filter: Filter = { name: 'John' } - const result = buildFilterClause(filter, tableName) - - expect(result).toBeDefined() + it('handles simple equality via JSONB containment', () => { + const out = render(buildFilterClause({ name: 'John' }, TABLE, NO_COLUMNS)) + expect(out).toContain('user_table_rows.data @>') + expect(out).toContain('"name":"John"') }) - it('should handle $eq operator', () => { - const filter: Filter = { status: { $eq: 'active' } } - const result = buildFilterClause(filter, tableName) - - expect(result).toBeDefined() + it('emits ::numeric cast for $gt on a number column', () => { + const cols: ColumnDefinition[] = [{ name: 'age', type: 'number' }] + const out = render(buildFilterClause({ age: { $gt: 18 } }, TABLE, cols)) + expect(out).toContain(`(${TABLE}.data->>'age')::numeric > `) + expect(out).not.toContain('::timestamp') }) - it('should handle $ne operator', () => { - const filter: Filter = { status: { $ne: 'deleted' } } - const result = buildFilterClause(filter, tableName) - - expect(result).toBeDefined() + it('falls back to ::numeric when column type is unknown', () => { + const out = render(buildFilterClause({ score: { $gte: 5 } }, TABLE, NO_COLUMNS)) + expect(out).toContain(`(${TABLE}.data->>'score')::numeric >= `) + expect(out).not.toContain('::timestamp') }) - it('should handle $gt operator', () => { - const filter: Filter = { age: { $gt: 18 } } - const result = buildFilterClause(filter, tableName) - - expect(result).toBeDefined() + it('handles $eq operator', () => { + const out = render(buildFilterClause({ status: { $eq: 'active' } }, TABLE, NO_COLUMNS)) + expect(out).toContain('"status":"active"') }) - it('should handle $gte operator', () => { - const filter: Filter = { age: { $gte: 18 } } - const result = buildFilterClause(filter, tableName) - - expect(result).toBeDefined() + it('handles $ne operator', () => { + const out = render(buildFilterClause({ status: { $ne: 'deleted' } }, TABLE, NO_COLUMNS)) + expect(out).toContain('NOT (') + expect(out).toContain('"status":"deleted"') }) - it('should handle $lt operator', () => { - const filter: Filter = { age: { $lt: 65 } } - const result = buildFilterClause(filter, tableName) - - expect(result).toBeDefined() + it('handles $in with multiple values via OR of containments', () => { + const out = render( + buildFilterClause({ status: { $in: ['active', 'pending'] } }, TABLE, NO_COLUMNS) + ) + expect(out).toContain(' OR ') + expect(out).toContain('"status":"active"') + expect(out).toContain('"status":"pending"') }) - it('should handle $lte operator', () => { - const filter: Filter = { age: { $lte: 65 } } - const result = buildFilterClause(filter, tableName) - - expect(result).toBeDefined() + it('handles $nin', () => { + const out = render( + buildFilterClause({ status: { $nin: ['deleted', 'archived'] } }, TABLE, NO_COLUMNS) + ) + expect(out).toContain('NOT (') + expect(out).toContain(' AND ') }) - it('should handle $in operator with single value', () => { - const filter: Filter = { status: { $in: ['active'] } } - const result = buildFilterClause(filter, tableName) - - expect(result).toBeDefined() + it('handles $contains as ILIKE', () => { + const out = render(buildFilterClause({ name: { $contains: 'john' } }, TABLE, NO_COLUMNS)) + expect(out).toContain(`${TABLE}.data->>'name'`) + expect(out).toContain('ILIKE') }) - it('should handle $in operator with multiple values', () => { - const filter: Filter = { status: { $in: ['active', 'pending'] } } - const result = buildFilterClause(filter, tableName) - - expect(result).toBeDefined() + it('joins multiple top-level conditions with AND', () => { + const out = render( + buildFilterClause({ status: 'active', age: { $gt: 18 } }, TABLE, NO_COLUMNS) + ) + expect(out).toContain(' AND ') }) - it('should handle $nin operator', () => { - const filter: Filter = { status: { $nin: ['deleted', 'archived'] } } - const result = buildFilterClause(filter, tableName) - - expect(result).toBeDefined() + it('handles $or logical operator', () => { + const out = render( + buildFilterClause({ $or: [{ status: 'active' }, { status: 'pending' }] }, TABLE, NO_COLUMNS) + ) + expect(out).toContain(' OR ') }) - it('should handle $contains operator', () => { - const filter: Filter = { name: { $contains: 'john' } } - const result = buildFilterClause(filter, tableName) - - expect(result).toBeDefined() + it('handles $and logical operator', () => { + const out = render( + buildFilterClause({ $and: [{ status: 'active' }, { age: { $gt: 18 } }] }, TABLE, NO_COLUMNS) + ) + expect(out).toContain(' AND ') }) - it('should handle $or logical operator', () => { - const filter: Filter = { - $or: [{ status: 'active' }, { status: 'pending' }], - } - const result = buildFilterClause(filter, tableName) - - expect(result).toBeDefined() + it('handles nested $or and $and', () => { + const out = render( + buildFilterClause( + { $or: [{ $and: [{ status: 'active' }, { verified: true }] }, { role: 'admin' }] }, + TABLE, + NO_COLUMNS + ) + ) + expect(out).toContain(' OR ') + expect(out).toContain(' AND ') }) - it('should handle $and logical operator', () => { - const filter: Filter = { - $and: [{ status: 'active' }, { age: { $gt: 18 } }], - } - const result = buildFilterClause(filter, tableName) - + it('skips undefined values', () => { + const result = buildFilterClause({ name: undefined, status: 'active' }, TABLE, NO_COLUMNS) expect(result).toBeDefined() }) - it('should handle multiple conditions combined with AND', () => { - const filter: Filter = { - status: 'active', - age: { $gt: 18 }, - } - const result = buildFilterClause(filter, tableName) - - expect(result).toBeDefined() + it('handles boolean / null / numeric primitives', () => { + expect(render(buildFilterClause({ active: true }, TABLE, NO_COLUMNS))).toContain( + '"active":true' + ) + expect(render(buildFilterClause({ deleted_at: null }, TABLE, NO_COLUMNS))).toContain( + '"deleted_at":null' + ) + expect(render(buildFilterClause({ count: 42 }, TABLE, NO_COLUMNS))).toContain('"count":42') }) - it('should handle nested $or and $and', () => { - const filter: Filter = { - $or: [{ $and: [{ status: 'active' }, { verified: true }] }, { role: 'admin' }], - } - const result = buildFilterClause(filter, tableName) - - expect(result).toBeDefined() + it('throws on invalid field name', () => { + expect(() => buildFilterClause({ 'invalid-field': 'v' }, TABLE, NO_COLUMNS)).toThrow( + 'Invalid field name' + ) }) - it('should throw error for invalid field name', () => { - const filter: Filter = { 'invalid-field': 'value' } - - expect(() => buildFilterClause(filter, tableName)).toThrow('Invalid field name') + it('throws on invalid operator', () => { + const f = { name: { $invalid: 'value' } } as unknown as Filter + expect(() => buildFilterClause(f, TABLE, NO_COLUMNS)).toThrow('Invalid operator') }) + }) - it('should throw error for invalid operator', () => { - const filter = { name: { $invalid: 'value' } } as unknown as Filter - - expect(() => buildFilterClause(filter, tableName)).toThrow('Invalid operator') + describe('buildFilterClause > date column type', () => { + const dateCols: ColumnDefinition[] = [{ name: 'birthDate', type: 'date' }] + + it.each([ + ['$gt', '>'], + ['$gte', '>='], + ['$lt', '<'], + ['$lte', '<='], + ] as const)('emits ::timestamptz on both sides for %s on a date column', (operator, sqlOp) => { + const filter = { birthDate: { [operator]: '2024-01-01' } } as Filter + const out = render(buildFilterClause(filter, TABLE, dateCols)) + expect(out).toContain(`(${TABLE}.data->>'birthDate')::timestamptz ${sqlOp} `) + expect(out).toContain('::timestamptz') + expect(out).not.toContain('::numeric') + // RHS cast — without it Postgres would compare as text (lexicographic). + expect(out.match(/::timestamptz/g)?.length).toBe(2) + }) + + it('combined range ($gte + $lte) emits two ::timestamptz pairs', () => { + const out = render( + buildFilterClause( + { birthDate: { $gte: '2024-01-01', $lte: '2024-12-31' } }, + TABLE, + dateCols + ) + ) + expect(out.match(/::timestamptz/g)?.length).toBe(4) + expect(out).not.toContain('::numeric') + expect(out).toContain(' AND ') + }) + + it('propagates date cast through nested $and', () => { + const out = render( + buildFilterClause( + { $and: [{ birthDate: { $gte: '2024-01-01' } }, { birthDate: { $lt: '2025-01-01' } }] }, + TABLE, + dateCols + ) + ) + expect(out).toContain('::timestamptz') + expect(out).not.toContain('::numeric') + }) + + it('propagates date cast through nested $or', () => { + const out = render( + buildFilterClause( + { $or: [{ birthDate: { $lt: '2000-01-01' } }, { birthDate: { $gt: '2024-01-01' } }] }, + TABLE, + dateCols + ) + ) + expect(out).toContain('::timestamptz') + expect(out).not.toContain('::numeric') + expect(out).toContain(' OR ') + }) + + it('a number column in the same query keeps ::numeric (no cross-contamination)', () => { + const cols: ColumnDefinition[] = [ + { name: 'birthDate', type: 'date' }, + { name: 'age', type: 'number' }, + ] + const out = render( + buildFilterClause({ birthDate: { $gte: '2024-01-01' }, age: { $gt: 18 } }, TABLE, cols) + ) + expect(out).toContain('::timestamptz') + expect(out).toContain('::numeric') }) + }) - it('should skip undefined values', () => { - const filter: Filter = { name: undefined, status: 'active' } - const result = buildFilterClause(filter, tableName) - - expect(result).toBeDefined() + describe('buildFilterClause > range operator value type validation', () => { + it('throws when $gt on a number column receives a string', () => { + const cols: ColumnDefinition[] = [{ name: 'age', type: 'number' }] + expect(() => buildFilterClause({ age: { $gt: 'eighteen' } } as Filter, TABLE, cols)).toThrow( + /column "age" \(number\) requires a number, got string/ + ) }) - it('should handle boolean values', () => { - const filter: Filter = { active: true } - const result = buildFilterClause(filter, tableName) - - expect(result).toBeDefined() + it('throws when $gte on a date column receives a number', () => { + const cols: ColumnDefinition[] = [{ name: 'birthDate', type: 'date' }] + expect(() => + buildFilterClause({ birthDate: { $gte: 1704067200000 } } as Filter, TABLE, cols) + ).toThrow(/column "birthDate" \(date\) requires a date string, got number/) }) - it('should handle null values', () => { - const filter: Filter = { deleted_at: null } - const result = buildFilterClause(filter, tableName) - - expect(result).toBeDefined() + it('throws when $lt on an unknown column (numeric fallback) receives a string', () => { + expect(() => + buildFilterClause({ score: { $lt: 'high' } } as Filter, TABLE, NO_COLUMNS) + ).toThrow(/column "score" \(number\) requires a number, got string/) }) - it('should handle numeric values', () => { - const filter: Filter = { count: 42 } - const result = buildFilterClause(filter, tableName) + it('accepts valid number on number column', () => { + const cols: ColumnDefinition[] = [{ name: 'age', type: 'number' }] + expect(() => buildFilterClause({ age: { $gt: 18 } }, TABLE, cols)).not.toThrow() + }) - expect(result).toBeDefined() + it('accepts valid ISO string on date column', () => { + const cols: ColumnDefinition[] = [{ name: 'birthDate', type: 'date' }] + expect(() => + buildFilterClause({ birthDate: { $gte: '2024-01-01' } }, TABLE, cols) + ).not.toThrow() }) }) describe('buildSortClause', () => { - const tableName = 'user_table_rows' - - it('should return undefined for empty sort', () => { - const result = buildSortClause({}, tableName) - expect(result).toBeUndefined() + it('returns undefined for empty sort', () => { + expect(buildSortClause({}, TABLE, NO_COLUMNS)).toBeUndefined() }) - it('should handle single field ascending sort', () => { - const sort = { name: 'asc' as const } - const result = buildSortClause(sort, tableName) - - expect(result).toBeDefined() + it('sorts string columns as text (no cast)', () => { + const cols: ColumnDefinition[] = [{ name: 'name', type: 'string' }] + const out = render(buildSortClause({ name: 'asc' }, TABLE, cols)) + expect(out).toBe(`${TABLE}.data->>'name' ASC`) + expect(out).not.toContain('::') }) - it('should handle single field descending sort', () => { - const sort = { name: 'desc' as const } - const result = buildSortClause(sort, tableName) - - expect(result).toBeDefined() + it('sorts number columns with ::numeric NULLS LAST', () => { + const cols: ColumnDefinition[] = [{ name: 'salary', type: 'number' }] + const out = render(buildSortClause({ salary: 'desc' }, TABLE, cols)) + expect(out).toBe(`(${TABLE}.data->>'salary')::numeric DESC NULLS LAST`) }) - it('should handle multiple fields sort', () => { - const sort = { name: 'asc' as const, created_at: 'desc' as const } - const result = buildSortClause(sort, tableName) - - expect(result).toBeDefined() + it('sorts date columns with ::timestamptz NULLS LAST', () => { + const cols: ColumnDefinition[] = [{ name: 'birthDate', type: 'date' }] + const out = render(buildSortClause({ birthDate: 'asc' }, TABLE, cols)) + expect(out).toBe(`(${TABLE}.data->>'birthDate')::timestamptz ASC NULLS LAST`) }) - it('should handle createdAt field directly', () => { - const sort = { createdAt: 'desc' as const } - const result = buildSortClause(sort, tableName) - - expect(result).toBeDefined() + it('sorts createdAt / updatedAt as direct column refs', () => { + expect(render(buildSortClause({ createdAt: 'desc' }, TABLE, NO_COLUMNS))).toBe( + `${TABLE}.createdAt DESC` + ) + expect(render(buildSortClause({ updatedAt: 'asc' }, TABLE, NO_COLUMNS))).toBe( + `${TABLE}.updatedAt ASC` + ) }) - it('should handle updatedAt field directly', () => { - const sort = { updatedAt: 'asc' as const } - const result = buildSortClause(sort, tableName) - - expect(result).toBeDefined() + it('combines multiple sort fields with commas', () => { + const cols: ColumnDefinition[] = [ + { name: 'name', type: 'string' }, + { name: 'salary', type: 'number' }, + ] + const out = render(buildSortClause({ name: 'asc', salary: 'desc' }, TABLE, cols)) + expect(out).toBe( + `${TABLE}.data->>'name' ASC, (${TABLE}.data->>'salary')::numeric DESC NULLS LAST` + ) }) - it('should throw error for invalid field name', () => { - const sort = { 'invalid-field': 'asc' as const } - - expect(() => buildSortClause(sort, tableName)).toThrow('Invalid field name') + it('falls back to text sort for unknown column types', () => { + const sort: Sort = { unknownField: 'asc' } + const out = render(buildSortClause(sort, TABLE, NO_COLUMNS)) + expect(out).toBe(`${TABLE}.data->>'unknownField' ASC`) }) - it('should throw error for invalid direction', () => { - const sort = { name: 'invalid' as 'asc' | 'desc' } - - expect(() => buildSortClause(sort, tableName)).toThrow('Invalid sort direction') - }) - - it('should handle numeric column type for proper numeric sorting', () => { - const sort = { salary: 'desc' as const } - const columns = [{ name: 'salary', type: 'number' as const }] - const result = buildSortClause(sort, tableName, columns) - - expect(result).toBeDefined() - }) - - it('should handle date column type for chronological sorting', () => { - const sort = { birthDate: 'asc' as const } - const columns = [{ name: 'birthDate', type: 'date' as const }] - const result = buildSortClause(sort, tableName, columns) - - expect(result).toBeDefined() - }) - - it('should use text sorting for string columns', () => { - const sort = { name: 'asc' as const } - const columns = [{ name: 'name', type: 'string' as const }] - const result = buildSortClause(sort, tableName, columns) - - expect(result).toBeDefined() + it('throws on invalid field name', () => { + const sort: Sort = { 'invalid-field': 'asc' } + expect(() => buildSortClause(sort, TABLE, NO_COLUMNS)).toThrow('Invalid field name') }) - it('should fall back to text sorting when column type is unknown', () => { - const sort = { unknownField: 'asc' as const } - // No columns provided - const result = buildSortClause(sort, tableName) - - expect(result).toBeDefined() + it('throws on invalid direction', () => { + const sort = { name: 'invalid' as 'asc' | 'desc' } + expect(() => buildSortClause(sort, TABLE, NO_COLUMNS)).toThrow('Invalid sort direction') }) }) - describe('Field Name Validation', () => { - const tableName = 'user_table_rows' - - it('should accept valid field names', () => { - const validNames = ['name', 'user_id', '_private', 'Count123', 'a'] - - for (const name of validNames) { - const filter: Filter = { [name]: 'value' } - expect(() => buildFilterClause(filter, tableName)).not.toThrow() + describe('Field name validation', () => { + it('accepts valid identifiers', () => { + const valid = ['name', 'user_id', '_private', 'Count123', 'a'] + for (const name of valid) { + expect(() => buildFilterClause({ [name]: 'v' }, TABLE, NO_COLUMNS)).not.toThrow() } }) - it('should reject field names starting with number', () => { - const filter: Filter = { '123name': 'value' } - expect(() => buildFilterClause(filter, tableName)).toThrow('Invalid field name') + it('rejects identifiers starting with a digit', () => { + expect(() => buildFilterClause({ '123name': 'v' }, TABLE, NO_COLUMNS)).toThrow( + 'Invalid field name' + ) }) - it('should reject field names with special characters', () => { - const invalidNames = ['field-name', 'field.name', 'field name', 'field@name'] - - for (const name of invalidNames) { - const filter: Filter = { [name]: 'value' } - expect(() => buildFilterClause(filter, tableName)).toThrow('Invalid field name') + it('rejects identifiers with special characters', () => { + const invalid = ['field-name', 'field.name', 'field name', 'field@name'] + for (const name of invalid) { + expect(() => buildFilterClause({ [name]: 'v' }, TABLE, NO_COLUMNS)).toThrow( + 'Invalid field name' + ) } }) - it('should reject SQL injection attempts', () => { - const sqlInjectionAttempts = ["'; DROP TABLE users; --", 'name OR 1=1', 'name; DELETE FROM'] - - for (const attempt of sqlInjectionAttempts) { - const filter: Filter = { [attempt]: 'value' } - expect(() => buildFilterClause(filter, tableName)).toThrow('Invalid field name') + it('rejects SQL injection attempts in field names', () => { + const attempts = ["'; DROP TABLE users; --", 'name OR 1=1', 'name; DELETE FROM'] + for (const a of attempts) { + expect(() => buildFilterClause({ [a]: 'v' }, TABLE, NO_COLUMNS)).toThrow( + 'Invalid field name' + ) } }) }) diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 972bb551026..693063bfdd4 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -1405,15 +1405,13 @@ export async function upsertRow( * (bounded only by the btree on `table_id`). Prefer equality on hot paths; set * `includeTotal: false` when the caller does not need the `COUNT(*)`. * - * @param tableId - Table ID to query - * @param workspaceId - Workspace ID for access control + * @param table - Table definition (provides id, workspaceId, and column schema for type-aware filter/sort casts) * @param options - Query options (filter, sort, limit, offset) * @param requestId - Request ID for logging * @returns Query result with rows and pagination info */ export async function queryRows( - tableId: string, - workspaceId: string, + table: TableDefinition, options: QueryOptions, requestId: string ): Promise { @@ -1426,16 +1424,17 @@ export async function queryRows( } = options const tableName = USER_TABLE_ROWS_SQL_NAME + const columns = table.schema.columns // Build WHERE clause const baseConditions = and( - eq(userTableRows.tableId, tableId), - eq(userTableRows.workspaceId, workspaceId) + eq(userTableRows.tableId, table.id), + eq(userTableRows.workspaceId, table.workspaceId) ) let whereClause = baseConditions if (filter && Object.keys(filter).length > 0) { - const filterClause = buildFilterClause(filter, tableName) + const filterClause = buildFilterClause(filter, tableName, columns) if (filterClause) { whereClause = and(baseConditions, filterClause) } @@ -1453,7 +1452,7 @@ export async function queryRows( // Build ORDER BY clause (default to position ASC for stable ordering) let orderByClause if (sort && Object.keys(sort).length > 0) { - orderByClause = buildSortClause(sort, tableName) + orderByClause = buildSortClause(sort, tableName, columns) } // Execute query @@ -1471,7 +1470,7 @@ export async function queryRows( const rows = await query.limit(limit).offset(offset) logger.info( - `[${requestId}] Queried ${rows.length} rows from table ${tableId} (total: ${totalCount})` + `[${requestId}] Queried ${rows.length} rows from table ${table.id} (total: ${totalCount})` ) return { @@ -1806,26 +1805,26 @@ export async function deleteRow( /** * Updates multiple rows matching a filter. * + * @param table - Table definition (provides column schema for type-aware filter casts) * @param data - Bulk update data - * @param table - Table definition * @param requestId - Request ID for logging * @returns Bulk operation result */ export async function updateRowsByFilter( - data: BulkUpdateData, table: TableDefinition, + data: BulkUpdateData, requestId: string ): Promise { const tableName = USER_TABLE_ROWS_SQL_NAME - const filterClause = buildFilterClause(data.filter, tableName) + const filterClause = buildFilterClause(data.filter, tableName, table.schema.columns) if (!filterClause) { throw new Error('Filter is required for bulk update') } const baseConditions = and( - eq(userTableRows.tableId, data.tableId), - eq(userTableRows.workspaceId, data.workspaceId) + eq(userTableRows.tableId, table.id), + eq(userTableRows.workspaceId, table.workspaceId) ) let query = db @@ -1873,7 +1872,7 @@ export async function updateRowsByFilter( const row = matchingRows[0] const mergedData = { ...(row.data as RowData), ...data.data } const uniqueValidation = await checkUniqueConstraintsDb( - data.tableId, + table.id, mergedData, table.schema, row.id @@ -1901,7 +1900,7 @@ export async function updateRowsByFilter( } }) - logger.info(`[${requestId}] Updated ${matchingRows.length} rows in table ${data.tableId}`) + logger.info(`[${requestId}] Updated ${matchingRows.length} rows in table ${table.id}`) const oldRows = new Map(matchingRows.map((r) => [r.id, r.data as RowData])) const updatedRows: TableRow[] = matchingRows.map((r) => ({ @@ -1913,7 +1912,7 @@ export async function updateRowsByFilter( updatedAt: now, })) void fireTableTrigger( - data.tableId, + table.id, table.name, 'update', updatedRows, @@ -2118,26 +2117,28 @@ async function recompactPositions(tableId: string, trx: DbTransaction, minDelete /** * Deletes multiple rows matching a filter. * + * @param table - Table definition (provides column schema for type-aware filter casts) * @param data - Bulk delete data * @param requestId - Request ID for logging * @returns Bulk operation result */ export async function deleteRowsByFilter( + table: TableDefinition, data: BulkDeleteData, requestId: string ): Promise { const tableName = USER_TABLE_ROWS_SQL_NAME // Build filter clause - const filterClause = buildFilterClause(data.filter, tableName) + const filterClause = buildFilterClause(data.filter, tableName, table.schema.columns) if (!filterClause) { throw new Error('Filter is required for bulk delete') } // Find matching rows const baseConditions = and( - eq(userTableRows.tableId, data.tableId), - eq(userTableRows.workspaceId, data.workspaceId) + eq(userTableRows.tableId, table.id), + eq(userTableRows.workspaceId, table.workspaceId) ) let query = db @@ -2167,8 +2168,8 @@ export async function deleteRowsByFilter( const batch = rowIds.slice(i, i + TABLE_LIMITS.DELETE_BATCH_SIZE) await trx.delete(userTableRows).where( and( - eq(userTableRows.tableId, data.tableId), - eq(userTableRows.workspaceId, data.workspaceId), + eq(userTableRows.tableId, table.id), + eq(userTableRows.workspaceId, table.workspaceId), sql`${userTableRows.id} = ANY(ARRAY[${sql.join( batch.map((id) => sql`${id}`), sql`, ` @@ -2177,10 +2178,10 @@ export async function deleteRowsByFilter( ) } - await recompactPositions(data.tableId, trx, minDeletedPos) + await recompactPositions(table.id, trx, minDeletedPos) }) - logger.info(`[${requestId}] Deleted ${matchingRows.length} rows from table ${data.tableId}`) + logger.info(`[${requestId}] Deleted ${matchingRows.length} rows from table ${table.id}`) return { affectedCount: matchingRows.length, diff --git a/apps/sim/lib/table/sql.ts b/apps/sim/lib/table/sql.ts index f854d2b5237..1b07407e998 100644 --- a/apps/sim/lib/table/sql.ts +++ b/apps/sim/lib/table/sql.ts @@ -21,6 +21,30 @@ export class TableQueryValidationError extends Error { } } +type ColumnType = ColumnDefinition['type'] +type ColumnTypeMap = ReadonlyMap + +/** + * Returns the Postgres cast needed to compare a JSONB text value of the given + * column type, or `null` when text comparison is correct. Single source of + * truth for both filter range operators and sort ordering — keeps the two + * paths from drifting apart. + */ +function jsonbCastForType(type: ColumnType | undefined): 'numeric' | 'timestamptz' | null { + switch (type) { + case 'number': + return 'numeric' + case 'date': + return 'timestamptz' + default: + return null + } +} + +function buildColumnTypeMap(columns: ColumnDefinition[]): ColumnTypeMap { + return new Map(columns.map((col) => [col.name, col.type])) +} + /** * Whitelist of allowed operators for query filtering. * Only these operators can be used in filter conditions. @@ -51,20 +75,42 @@ const ALLOWED_OPERATORS = new Set([ * * @param filter - Filter object with field conditions and logical operators * @param tableName - Table name for the query (e.g., 'user_table_rows') + * @param columns - Column definitions; drives type-aware JSONB casts (numeric for numbers, timestamptz for dates) * @returns SQL WHERE clause or undefined if no filter specified * @throws {TableQueryValidationError} if field name is invalid or operator is not allowed * * @example * // Simple equality - * buildFilterClause({ name: 'John' }, 'user_table_rows') + * buildFilterClause({ name: 'John' }, 'user_table_rows', [{ name: 'name', type: 'string' }]) * - * // Complex filter with operators - * buildFilterClause({ age: { $gte: 18 }, status: { $in: ['active', 'pending'] } }, 'user_table_rows') + * // Range on a date column — emits `::timestamptz` on both sides + * buildFilterClause( + * { birthDate: { $gte: '2024-01-01' } }, + * 'user_table_rows', + * [{ name: 'birthDate', type: 'date' }], + * ) * * // Logical operators - * buildFilterClause({ $or: [{ status: 'active' }, { verified: true }] }, 'user_table_rows') + * buildFilterClause( + * { $or: [{ status: 'active' }, { verified: true }] }, + * 'user_table_rows', + * [{ name: 'status', type: 'string' }, { name: 'verified', type: 'boolean' }], + * ) */ -export function buildFilterClause(filter: Filter, tableName: string): SQL | undefined { +export function buildFilterClause( + filter: Filter, + tableName: string, + columns: ColumnDefinition[] +): SQL | undefined { + const columnTypeMap = buildColumnTypeMap(columns) + return buildFilterClauseInternal(filter, tableName, columnTypeMap) +} + +function buildFilterClauseInternal( + filter: Filter, + tableName: string, + columnTypeMap: ColumnTypeMap +): SQL | undefined { const conditions: SQL[] = [] for (const [field, condition] of Object.entries(filter)) { @@ -75,7 +121,7 @@ export function buildFilterClause(filter: Filter, tableName: string): SQL | unde // This represents a case where the filter is a logical OR of multiple filters // e.g. { $or: [{ status: 'active' }, { status: 'pending' }] } if (field === '$or' && Array.isArray(condition)) { - const orClause = buildLogicalClause(condition as Filter[], tableName, 'OR') + const orClause = buildLogicalClause(condition as Filter[], tableName, 'OR', columnTypeMap) if (orClause) { conditions.push(orClause) } @@ -85,7 +131,7 @@ export function buildFilterClause(filter: Filter, tableName: string): SQL | unde // This represents a case where the filter is a logical AND of multiple filters // e.g. { $and: [{ status: 'active' }, { status: 'pending' }] } if (field === '$and' && Array.isArray(condition)) { - const andClause = buildLogicalClause(condition as Filter[], tableName, 'AND') + const andClause = buildLogicalClause(condition as Filter[], tableName, 'AND', columnTypeMap) if (andClause) { conditions.push(andClause) } @@ -103,7 +149,8 @@ export function buildFilterClause(filter: Filter, tableName: string): SQL | unde const fieldConditions = buildFieldCondition( tableName, field, - condition as JsonValue | ConditionOperators + condition as JsonValue | ConditionOperators, + columnTypeMap.get(field) ) conditions.push(...fieldConditions) } @@ -119,26 +166,33 @@ export function buildFilterClause(filter: Filter, tableName: string): SQL | unde * * @param sort - Sort object with field names and directions * @param tableName - Table name for the query (e.g., 'user_table_rows') - * @param columns - Optional column definitions for type-aware sorting + * @param columns - Column definitions; drives type-aware casts (numeric for numbers, timestamptz for dates) * @returns SQL ORDER BY clause or undefined if no sort specified * @throws {TableQueryValidationError} if field name or sort direction is invalid * * @example - * buildSortClause({ name: 'asc', age: 'desc' }, 'user_table_rows') - * // Returns: ORDER BY data->>'name' ASC, data->>'age' DESC + * buildSortClause( + * { name: 'asc' }, + * 'user_table_rows', + * [{ name: 'name', type: 'string' }], + * ) + * // Returns: ORDER BY user_table_rows.data->>'name' ASC * * @example - * // With column types for proper numeric sorting - * buildSortClause({ salary: 'desc' }, 'user_table_rows', [{ name: 'salary', type: 'number' }]) - * // Returns: ORDER BY (data->>'salary')::numeric DESC NULLS LAST + * buildSortClause( + * { salary: 'desc' }, + * 'user_table_rows', + * [{ name: 'salary', type: 'number' }], + * ) + * // Returns: ORDER BY (user_table_rows.data->>'salary')::numeric DESC NULLS LAST */ export function buildSortClause( sort: Sort, tableName: string, - columns?: ColumnDefinition[] + columns: ColumnDefinition[] ): SQL | undefined { const clauses: SQL[] = [] - const columnTypeMap = new Map(columns?.map((col) => [col.name, col.type])) + const columnTypeMap = buildColumnTypeMap(columns) for (const [field, direction] of Object.entries(sort)) { validateFieldName(field) @@ -189,6 +243,31 @@ function validateOperator(operator: string): void { } } +/** + * Validates that a range-operator value matches its column's expected JS type + * before it reaches Postgres. Surfaces an actionable, column-named error at the + * SQL builder layer instead of a generic `invalid input syntax for type numeric` + * from the database. + */ +function validateComparisonValue( + field: string, + columnType: ColumnType | undefined, + cast: 'numeric' | 'timestamptz', + value: number | string +): void { + if (cast === 'numeric' && typeof value !== 'number') { + const label = columnType ?? 'number' + throw new TableQueryValidationError( + `Range operator on column "${field}" (${label}) requires a number, got ${typeof value}` + ) + } + if (cast === 'timestamptz' && typeof value !== 'string') { + throw new TableQueryValidationError( + `Range operator on column "${field}" (date) requires a date string, got ${typeof value}` + ) + } +} + /** * Builds SQL conditions for a single field based on the provided condition. * @@ -208,7 +287,8 @@ function validateOperator(operator: string): void { function buildFieldCondition( tableName: string, field: string, - condition: JsonValue | ConditionOperators + condition: JsonValue | ConditionOperators, + columnType: ColumnType | undefined ): SQL[] { validateFieldName(field) @@ -231,19 +311,27 @@ function buildFieldCondition( break case '$gt': - conditions.push(buildComparisonClause(tableName, field, '>', value as number)) + conditions.push( + buildComparisonClause(tableName, field, '>', value as number | string, columnType) + ) break case '$gte': - conditions.push(buildComparisonClause(tableName, field, '>=', value as number)) + conditions.push( + buildComparisonClause(tableName, field, '>=', value as number | string, columnType) + ) break case '$lt': - conditions.push(buildComparisonClause(tableName, field, '<', value as number)) + conditions.push( + buildComparisonClause(tableName, field, '<', value as number | string, columnType) + ) break case '$lte': - conditions.push(buildComparisonClause(tableName, field, '<=', value as number)) + conditions.push( + buildComparisonClause(tableName, field, '<=', value as number | string, columnType) + ) break case '$in': @@ -312,11 +400,12 @@ function buildFieldCondition( function buildLogicalClause( subFilters: Filter[], tableName: string, - operator: 'OR' | 'AND' + operator: 'OR' | 'AND', + columnTypeMap: ColumnTypeMap ): SQL | undefined { const clauses: SQL[] = [] for (const subFilter of subFilters) { - const clause = buildFilterClause(subFilter, tableName) + const clause = buildFilterClauseInternal(subFilter, tableName, columnTypeMap) if (clause) { clauses.push(clause) } @@ -334,15 +423,36 @@ function buildContainmentClause(tableName: string, field: string, value: JsonVal return sql`${sql.raw(`${tableName}.data`)} @> ${jsonObj}::jsonb` } -/** Builds numeric comparison: `(data->>'field')::numeric value` (cannot use GIN index) */ +/** + * Builds a typed range comparison against a JSONB cell. + * + * `number` columns cast both sides to `numeric`; `date` columns cast both sides + * to `timestamptz` so date strings compare chronologically and timezone offsets + * in ISO strings (e.g. `2024-01-01T00:00:00Z`) are preserved rather than + * silently stripped (which would make results depend on the server's TimeZone + * setting). Unknown/other types + * fall back to `numeric` (legacy default — preserves behavior for ad-hoc fields + * with no schema entry). The right-hand value is cast explicitly because + * drizzle parameterizes it as `text`; without the cast, Postgres would compare + * `text text` and silently produce lexicographic results. + * + * Cannot use the GIN index — falls back to a sequential scan over the table's + * rows (bounded by the btree prefix on `table_id`). + */ function buildComparisonClause( tableName: string, field: string, operator: '>' | '>=' | '<' | '<=', - value: number + value: number | string, + columnType: ColumnType | undefined ): SQL { const escapedField = field.replace(/'/g, "''") - return sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric ${sql.raw(operator)} ${value}` + const cast = jsonbCastForType(columnType) ?? 'numeric' + validateComparisonValue(field, columnType, cast, value) + const cell = sql.raw(`(${tableName}.data->>'${escapedField}')::${cast}`) + return cast === 'timestamptz' + ? sql`${cell} ${sql.raw(operator)} ${value}::timestamptz` + : sql`${cell} ${sql.raw(operator)} ${value}` } /** Escapes LIKE/ILIKE wildcard characters so they match literally */ @@ -370,7 +480,7 @@ function buildSortFieldClause( tableName: string, field: string, direction: 'asc' | 'desc', - columnType?: string + columnType: ColumnType | undefined ): SQL { const escapedField = field.replace(/'/g, "''") const directionSql = direction.toUpperCase() @@ -380,18 +490,13 @@ function buildSortFieldClause( } const jsonbExtract = `${tableName}.data->>'${escapedField}'` + const cast = jsonbCastForType(columnType) - // Cast to appropriate type for correct sorting - if (columnType === 'number') { - // Cast to numeric, with NULLS LAST to handle null/invalid values - return sql.raw(`(${jsonbExtract})::numeric ${directionSql} NULLS LAST`) - } - - if (columnType === 'date') { - // Cast to timestamp for chronological sorting - return sql.raw(`(${jsonbExtract})::timestamp ${directionSql} NULLS LAST`) + if (cast === null) { + // Sort as text (string, boolean, json, or unknown types) + return sql.raw(`${jsonbExtract} ${directionSql}`) } - // Default: sort as text (for string, boolean, json, or unknown types) - return sql.raw(`${jsonbExtract} ${directionSql}`) + // NULLS LAST so rows with null/invalid values sort to the bottom regardless of direction + return sql.raw(`(${jsonbExtract})::${cast} ${directionSql} NULLS LAST`) } diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 5d6b90d8413..52e81b73f77 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -163,10 +163,10 @@ export interface TableRow { export interface ConditionOperators { $eq?: ColumnValue $ne?: ColumnValue - $gt?: number - $gte?: number - $lt?: number - $lte?: number + $gt?: number | string + $gte?: number | string + $lt?: number | string + $lte?: number | string $in?: ColumnValue[] $nin?: ColumnValue[] $contains?: string @@ -319,11 +319,9 @@ export interface UpdateRowData { } export interface BulkUpdateData { - tableId: string filter: Filter data: RowData limit?: number - workspaceId: string } export interface BatchUpdateByIdData { @@ -339,10 +337,8 @@ export interface BatchUpdateByIdData { } export interface BulkDeleteData { - tableId: string filter: Filter limit?: number - workspaceId: string } export interface BulkDeleteByIdsData { From 6827be7f4328ae3d953102fdd765607707c7a643 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 18 May 2026 19:33:33 -0700 Subject: [PATCH 10/10] feat(google_docs): opt-in Markdown formatting for create operation (#4656) * feat(google_docs): opt-in Markdown formatting for create operation * fix(google_docs): harden multipart boundary handoff and align postProcess guard --- apps/sim/blocks/blocks/google_docs.ts | 13 ++++ apps/sim/tools/google_docs/create.ts | 86 +++++++++++++++++++++++---- apps/sim/tools/google_docs/types.ts | 1 + apps/sim/tools/google_docs/write.ts | 3 +- 4 files changed, 91 insertions(+), 12 deletions(-) diff --git a/apps/sim/blocks/blocks/google_docs.ts b/apps/sim/blocks/blocks/google_docs.ts index a24832d7eb9..dca1cb50f82 100644 --- a/apps/sim/blocks/blocks/google_docs.ts +++ b/apps/sim/blocks/blocks/google_docs.ts @@ -155,6 +155,15 @@ Return ONLY the document content - no explanations, no extra text.`, placeholder: 'Describe the document content you want to create...', }, }, + // Markdown formatting toggle for create operation + { + id: 'markdown', + title: 'Interpret content as Markdown', + type: 'switch', + condition: { field: 'operation', value: 'create' }, + description: + 'Convert headings, bold/italic, lists, tables, links, code, and blockquotes into formatted Google Docs content. When off, content is inserted as plain text.', + }, ], tools: { access: ['google_docs_read', 'google_docs_write', 'google_docs_create'], @@ -193,6 +202,10 @@ Return ONLY the document content - no explanations, no extra text.`, title: { type: 'string', description: 'Document title' }, folderId: { type: 'string', description: 'Parent folder identifier (canonical param)' }, content: { type: 'string', description: 'Document content' }, + markdown: { + type: 'boolean', + description: 'Interpret content as Markdown when creating a document', + }, }, outputs: { content: { type: 'string', description: 'Document content' }, diff --git a/apps/sim/tools/google_docs/create.ts b/apps/sim/tools/google_docs/create.ts index 0a832c59105..1b6e50a0d8c 100644 --- a/apps/sim/tools/google_docs/create.ts +++ b/apps/sim/tools/google_docs/create.ts @@ -1,9 +1,37 @@ import { createLogger } from '@sim/logger' +import { generateShortId } from '@sim/utils/id' import type { GoogleDocsCreateResponse, GoogleDocsToolParams } from '@/tools/google_docs/types' import type { ToolConfig } from '@/tools/types' const logger = createLogger('GoogleDocsCreateTool') +const DOC_MIME_TYPE = 'application/vnd.google-apps.document' + +/** + * Build a multipart/related body for Drive's files.create upload endpoint. + * Used when converting Markdown to a Google Doc in a single round-trip. + * See: https://developers.google.com/workspace/drive/api/guides/manage-uploads + */ +function buildMarkdownMultipartBody( + metadata: Record, + markdownContent: string, + boundary: string +): string { + return ( + `--${boundary}\r\n` + + `Content-Type: application/json; charset=UTF-8\r\n\r\n` + + `${JSON.stringify(metadata)}\r\n` + + `--${boundary}\r\n` + + `Content-Type: text/markdown\r\n\r\n` + + `${markdownContent}\r\n` + + `--${boundary}--` + ) +} + +function shouldUseMarkdownUpload(params: GoogleDocsToolParams): boolean { + return Boolean(params.markdown && params.content) +} + export const createTool: ToolConfig = { id: 'google_docs_create', name: 'Create Google Docs Document', @@ -46,19 +74,37 @@ export const createTool: ToolConfig { - return 'https://www.googleapis.com/drive/v3/files?supportsAllDrives=true' + url: (params) => { + return shouldUseMarkdownUpload(params) + ? 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true' + : 'https://www.googleapis.com/drive/v3/files?supportsAllDrives=true' }, method: 'POST', headers: (params) => { - // Validate access token if (!params.accessToken) { throw new Error('Access token is required') } + if (shouldUseMarkdownUpload(params)) { + const boundary = `sim_gdocs_md_${generateShortId(24)}` + // Stash on params so body() uses the matching boundary string + ;(params as GoogleDocsToolParams & { _boundary?: string })._boundary = boundary + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': `multipart/related; boundary=${boundary}`, + } + } + return { Authorization: `Bearer ${params.accessToken}`, 'Content-Type': 'application/json', @@ -69,18 +115,30 @@ export const createTool: ToolConfig = { name: params.title, - mimeType: 'application/vnd.google-apps.document', + mimeType: DOC_MIME_TYPE, } - - // Add parent folder if specified (prefer folderSelector over folderId) - const folderId = params.folderSelector || params.folderId if (folderId) { - requestBody.parents = [folderId] + metadata.parents = [folderId] + } + + if (shouldUseMarkdownUpload(params)) { + const boundary = (params as GoogleDocsToolParams & { _boundary?: string })._boundary + if (!boundary) { + // headers() runs before body() in formatRequestParams and stashes the boundary + // on the same params reference. Missing _boundary means that contract was broken, + // which would silently produce a Content-Type / body boundary mismatch (HTTP 400). + // Throw loudly instead of fabricating a mismatched boundary. + throw new Error( + 'Multipart boundary missing on params — headers() must run before body() for markdown upload' + ) + } + return buildMarkdownMultipartBody(metadata, params.content ?? '', boundary) } - return requestBody + return metadata }, }, @@ -91,6 +149,12 @@ export const createTool: ToolConfig = { id: 'google_docs_write', name: 'Write to Google Docs Document', - description: 'Write or update content in a Google Docs document', + description: + 'Append content to a Google Docs document. Content is inserted literally; Markdown is not interpreted. For formatted output from Markdown, use the Create operation with the markdown toggle enabled.', version: '1.0', oauth: { required: true,