From b1aa8266fb9586d21819561b0b4d134d8bc739f7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 25 Feb 2026 11:55:38 -0800 Subject: [PATCH 01/12] feat(workflow): lock/unlock workflow from context menu and panel --- .../components/canvas-menu/canvas-menu.tsx | 27 +++++++ .../notifications/notifications.tsx | 7 +- .../w/[workflowId]/components/panel/panel.tsx | 37 ++++++++- .../workflow-block/workflow-block.tsx | 2 +- .../utils/block-protection-utils.ts | 29 +++++++ .../[workspaceId]/w/[workflowId]/workflow.tsx | 79 +++++++++++++++++++ apps/sim/stores/notifications/types.ts | 2 +- 7 files changed, 179 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx index e091849c82..decce65644 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx @@ -1,6 +1,7 @@ 'use client' import type { RefObject } from 'react' +import { Lock, Unlock } from 'lucide-react' import { Popover, PopoverAnchor, @@ -26,16 +27,22 @@ export interface CanvasMenuProps { onOpenLogs: () => void onToggleVariables: () => void onToggleChat: () => void + onToggleWorkflowLock?: () => void isVariablesOpen?: boolean isChatOpen?: boolean hasClipboard?: boolean disableEdit?: boolean disableAdmin?: boolean + canAdmin?: boolean canUndo?: boolean canRedo?: boolean isInvitationsDisabled?: boolean /** Whether the workflow has locked blocks (disables auto-layout) */ hasLockedBlocks?: boolean + /** Whether all blocks in the workflow are locked */ + allBlocksLocked?: boolean + /** Whether the workflow has any blocks */ + hasBlocks?: boolean } /** @@ -56,13 +63,17 @@ export function CanvasMenu({ onOpenLogs, onToggleVariables, onToggleChat, + onToggleWorkflowLock, isVariablesOpen = false, isChatOpen = false, hasClipboard = false, disableEdit = false, + canAdmin = false, canUndo = false, canRedo = false, hasLockedBlocks = false, + allBlocksLocked = false, + hasBlocks = false, }: CanvasMenuProps) { return ( Auto-layout ⇧L + {canAdmin && onToggleWorkflowLock && ( + { + onToggleWorkflowLock() + onClose() + }} + > + {allBlocksLocked ? ( + + ) : ( + + )} + {allBlocksLocked ? 'Unlock workflow' : 'Lock workflow'} + + )} { onFitToView() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx index ddd25134fc..cd5d8095b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx @@ -61,6 +61,9 @@ export const Notifications = memo(function Notifications() { case 'refresh': window.location.reload() break + case 'unlock-workflow': + window.dispatchEvent(new CustomEvent('unlock-workflow')) + break default: logger.warn('Unknown action type', { notificationId, actionType: action.type }) } @@ -175,7 +178,9 @@ export const Notifications = memo(function Notifications() { ? 'Fix in Copilot' : notification.action!.type === 'refresh' ? 'Refresh' - : 'Take action'} + : notification.action!.type === 'unlock-workflow' + ? 'Unlock Workflow' + : 'Take action'} )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 03510936d7..03e595230c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -2,7 +2,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { ArrowUp, Square } from 'lucide-react' +import { ArrowUp, Lock, Square, Unlock } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { useShallow } from 'zustand/react/shallow' import { @@ -42,7 +42,9 @@ import { import { Variables } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables' import { useAutoLayout } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-auto-layout' import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' +import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks' +import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useChatStore } from '@/stores/chat/store' import { useNotificationStore } from '@/stores/notifications/store' @@ -126,6 +128,15 @@ export const Panel = memo(function Panel() { Object.values(state.blocks).some((block) => block.locked) ) + const allBlocksLocked = useWorkflowStore((state) => { + const blockList = Object.values(state.blocks) + return blockList.length > 0 && blockList.every((block) => block.locked) + }) + + const hasBlocks = useWorkflowStore((state) => Object.keys(state.blocks).length > 0) + + const { collaborativeBatchToggleLocked } = useCollaborativeWorkflow() + // Delete workflow hook const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({ workspaceId, @@ -329,6 +340,17 @@ export const Panel = memo(function Panel() { workspaceId, ]) + /** + * Toggles the locked state of all blocks in the workflow + */ + const handleToggleWorkflowLock = useCallback(() => { + const blocks = useWorkflowStore.getState().blocks + const allLocked = Object.values(blocks).every((b) => b.locked) + const ids = getWorkflowLockToggleIds(blocks, !allLocked) + if (ids.length > 0) collaborativeBatchToggleLocked(ids) + setIsMenuOpen(false) + }, [collaborativeBatchToggleLocked]) + // Compute run button state const canRun = userPermissions.canRead // Running only requires read permissions const isLoadingPermissions = userPermissions.isLoading @@ -399,6 +421,19 @@ export const Panel = memo(function Panel() { Auto layout + {userPermissions.canAdmin && ( + + {allBlocksLocked ? ( + + ) : ( + + )} + {allBlocksLocked ? 'Unlock workflow' : 'Lock workflow'} + + )} { setVariablesOpen(!isVariablesOpen)}> diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 07bf5e1430..a20c1c356f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -1298,7 +1298,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({ )} - {!isEnabled && disabled} + {!isEnabled && !isLocked && disabled} {isLocked && locked} {type === 'schedule' && shouldShowScheduleBadge && scheduleInfo?.isDisabled && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils.ts index d86f1b3dce..bcfdf9fe7b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils.ts @@ -58,6 +58,35 @@ export function isEdgeProtected( * @param blocks - Record of all blocks in the workflow * @returns Result containing deletable IDs, protected IDs, and whether all are protected */ +/** + * Returns block IDs ordered so that `batchToggleLocked` will target the desired state. + * + * `batchToggleLocked` determines its target locked state from `!firstBlock.locked`. + * When `targetLocked` is true (lock all), an unlocked block must come first. + * When `targetLocked` is false (unlock all), a locked block must come first. + * + * @param blocks - Record of all blocks in the workflow + * @param targetLocked - The desired locked state for all blocks + * @returns Sorted block IDs, or empty array if there are no blocks + */ +export function getWorkflowLockToggleIds( + blocks: Record, + targetLocked: boolean +): string[] { + const ids = Object.keys(blocks) + if (ids.length === 0) return [] + + ids.sort((a, b) => { + const aVal = blocks[a].locked ? 1 : 0 + const bVal = blocks[b].locked ? 1 : 0 + // To lock all (targetLocked=true): unlocked first (aVal - bVal) + // To unlock all (targetLocked=false): locked first (bVal - aVal) + return targetLocked ? aVal - bVal : bVal - aVal + }) + + return ids +} + export function filterProtectedBlocks( blockIds: string[], blocks: Record diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index f88b9d9122..2a53583818 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -57,6 +57,7 @@ import { estimateBlockDimensions, filterProtectedBlocks, getClampedPositionForNode, + getWorkflowLockToggleIds, isBlockProtected, isEdgeProtected, isInEditableElement, @@ -393,6 +394,13 @@ const WorkflowContent = React.memo(() => { const { blocks, edges, lastSaved } = currentWorkflow + const allBlocksLocked = useMemo(() => { + const blockList = Object.values(blocks) + return blockList.length > 0 && blockList.every((b) => b.locked) + }, [blocks]) + + const hasBlocks = useMemo(() => Object.keys(blocks).length > 0, [blocks]) + const isWorkflowReady = useMemo( () => hydration.phase === 'ready' && @@ -1175,6 +1183,73 @@ const WorkflowContent = React.memo(() => { collaborativeBatchToggleLocked(blockIds) }, [contextMenuBlocks, collaborativeBatchToggleLocked]) + const handleToggleWorkflowLock = useCallback(() => { + const currentBlocks = useWorkflowStore.getState().blocks + const allLocked = Object.values(currentBlocks).every((b) => b.locked) + const ids = getWorkflowLockToggleIds(currentBlocks, !allLocked) + if (ids.length > 0) collaborativeBatchToggleLocked(ids) + }, [collaborativeBatchToggleLocked]) + + // Show notification when all blocks in the workflow are locked + const lockNotificationIdRef = useRef(null) + + const clearLockNotification = useCallback(() => { + if (lockNotificationIdRef.current) { + useNotificationStore.getState().removeNotification(lockNotificationIdRef.current) + lockNotificationIdRef.current = null + } + }, []) + + // Reset notification when switching workflows so it recreates for the new workflow + const prevWorkflowIdRef = useRef(activeWorkflowId) + const prevCanAdminRef = useRef(effectivePermissions.canAdmin) + useEffect(() => { + if (!isWorkflowReady) return + + const workflowChanged = prevWorkflowIdRef.current !== activeWorkflowId + const canAdminChanged = prevCanAdminRef.current !== effectivePermissions.canAdmin + prevWorkflowIdRef.current = activeWorkflowId + prevCanAdminRef.current = effectivePermissions.canAdmin + + // Clear stale notification when workflow or admin status changes + if ((workflowChanged || canAdminChanged) && lockNotificationIdRef.current) { + clearLockNotification() + } + + if (allBlocksLocked) { + if (lockNotificationIdRef.current) return + + const isAdmin = effectivePermissions.canAdmin + lockNotificationIdRef.current = addNotification({ + level: 'info', + message: isAdmin + ? 'This workflow is locked' + : 'This workflow is locked. Ask an admin to unlock it.', + workflowId: activeWorkflowId || undefined, + ...(isAdmin + ? { action: { type: 'unlock-workflow' as const, message: '' } } + : {}), + }) + } else { + clearLockNotification() + } + }, [allBlocksLocked, isWorkflowReady, effectivePermissions.canAdmin, addNotification, activeWorkflowId, clearLockNotification]) + + // Clean up notification on unmount + useEffect(() => clearLockNotification, [clearLockNotification]) + + // Listen for unlock-workflow events from notification action button + useEffect(() => { + const handleUnlockWorkflow = () => { + const currentBlocks = useWorkflowStore.getState().blocks + const ids = getWorkflowLockToggleIds(currentBlocks, false) + if (ids.length > 0) collaborativeBatchToggleLocked(ids) + } + + window.addEventListener('unlock-workflow', handleUnlockWorkflow) + return () => window.removeEventListener('unlock-workflow', handleUnlockWorkflow) + }, [collaborativeBatchToggleLocked]) + const handleContextRemoveFromSubflow = useCallback(() => { const blocksToRemove = contextMenuBlocks.filter( (block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel') @@ -3700,6 +3775,10 @@ const WorkflowContent = React.memo(() => { canUndo={canUndo} canRedo={canRedo} hasLockedBlocks={Object.values(blocks).some((b) => b.locked)} + onToggleWorkflowLock={handleToggleWorkflowLock} + allBlocksLocked={allBlocksLocked} + canAdmin={effectivePermissions.canAdmin} + hasBlocks={hasBlocks} /> )} diff --git a/apps/sim/stores/notifications/types.ts b/apps/sim/stores/notifications/types.ts index 678f540ce8..627b09bb66 100644 --- a/apps/sim/stores/notifications/types.ts +++ b/apps/sim/stores/notifications/types.ts @@ -6,7 +6,7 @@ export interface NotificationAction { /** * Action type identifier for handler reconstruction */ - type: 'copilot' | 'refresh' + type: 'copilot' | 'refresh' | 'unlock-workflow' /** * Message or data to pass to the action handler. From 99d9239acb33977f51e95e01c6ad36fd609f4599 Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 25 Feb 2026 13:27:51 -0800 Subject: [PATCH 02/12] lint --- .../components/canvas-menu/canvas-menu.tsx | 6 +----- .../w/[workflowId]/components/panel/panel.tsx | 5 +---- .../[workspaceId]/w/[workflowId]/workflow.tsx | 13 +++++++++---- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx index decce65644..afbb5eda68 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx @@ -161,11 +161,7 @@ export function CanvasMenu({ onClose() }} > - {allBlocksLocked ? ( - - ) : ( - - )} + {allBlocksLocked ? : } {allBlocksLocked ? 'Unlock workflow' : 'Lock workflow'} )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 03e595230c..df35a17fd9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -422,10 +422,7 @@ export const Panel = memo(function Panel() { Auto layout {userPermissions.canAdmin && ( - + {allBlocksLocked ? ( ) : ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 2a53583818..2d3b24c7ec 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -1226,14 +1226,19 @@ const WorkflowContent = React.memo(() => { ? 'This workflow is locked' : 'This workflow is locked. Ask an admin to unlock it.', workflowId: activeWorkflowId || undefined, - ...(isAdmin - ? { action: { type: 'unlock-workflow' as const, message: '' } } - : {}), + ...(isAdmin ? { action: { type: 'unlock-workflow' as const, message: '' } } : {}), }) } else { clearLockNotification() } - }, [allBlocksLocked, isWorkflowReady, effectivePermissions.canAdmin, addNotification, activeWorkflowId, clearLockNotification]) + }, [ + allBlocksLocked, + isWorkflowReady, + effectivePermissions.canAdmin, + addNotification, + activeWorkflowId, + clearLockNotification, + ]) // Clean up notification on unmount useEffect(() => clearLockNotification, [clearLockNotification]) From 66dfa126b67f9272fa59a91dff510a34940215bb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 25 Feb 2026 13:49:04 -0800 Subject: [PATCH 03/12] fix(workflow): prevent duplicate lock notifications, no-op guard, fix orphaned JSDoc --- .../utils/block-protection-utils.ts | 36 +++++++++++-------- .../[workspaceId]/w/[workflowId]/workflow.tsx | 25 +++++++++---- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils.ts index bcfdf9fe7b..602a8d784a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils.ts @@ -58,6 +58,20 @@ export function isEdgeProtected( * @param blocks - Record of all blocks in the workflow * @returns Result containing deletable IDs, protected IDs, and whether all are protected */ +export function filterProtectedBlocks( + blockIds: string[], + blocks: Record +): FilterProtectedBlocksResult { + const protectedIds = blockIds.filter((id) => isBlockProtected(id, blocks)) + const deletableIds = blockIds.filter((id) => !protectedIds.includes(id)) + + return { + deletableIds, + protectedIds, + allProtected: protectedIds.length === blockIds.length && blockIds.length > 0, + } +} + /** * Returns block IDs ordered so that `batchToggleLocked` will target the desired state. * @@ -65,9 +79,11 @@ export function isEdgeProtected( * When `targetLocked` is true (lock all), an unlocked block must come first. * When `targetLocked` is false (unlock all), a locked block must come first. * + * Returns an empty array when there are no blocks or all blocks already match `targetLocked`. + * * @param blocks - Record of all blocks in the workflow * @param targetLocked - The desired locked state for all blocks - * @returns Sorted block IDs, or empty array if there are no blocks + * @returns Sorted block IDs, or empty array if no toggle is needed */ export function getWorkflowLockToggleIds( blocks: Record, @@ -76,6 +92,10 @@ export function getWorkflowLockToggleIds( const ids = Object.keys(blocks) if (ids.length === 0) return [] + // No-op if all blocks already match the desired state + const allMatch = Object.values(blocks).every((b) => Boolean(b.locked) === targetLocked) + if (allMatch) return [] + ids.sort((a, b) => { const aVal = blocks[a].locked ? 1 : 0 const bVal = blocks[b].locked ? 1 : 0 @@ -86,17 +106,3 @@ export function getWorkflowLockToggleIds( return ids } - -export function filterProtectedBlocks( - blockIds: string[], - blocks: Record -): FilterProtectedBlocksResult { - const protectedIds = blockIds.filter((id) => isBlockProtected(id, blocks)) - const deletableIds = blockIds.filter((id) => !protectedIds.includes(id)) - - return { - deletableIds, - protectedIds, - allProtected: protectedIds.length === blockIds.length && blockIds.length > 0, - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 2d3b24c7ec..97c2978b09 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -1200,19 +1200,32 @@ const WorkflowContent = React.memo(() => { } }, []) - // Reset notification when switching workflows so it recreates for the new workflow - const prevWorkflowIdRef = useRef(activeWorkflowId) + // Clear persisted lock notifications on mount/workflow change (prevents duplicates after reload) + useEffect(() => { + // Reset ref so the main effect creates a fresh notification for the new workflow + clearLockNotification() + + if (!activeWorkflowId) return + const store = useNotificationStore.getState() + const stale = store.notifications.filter( + (n) => + n.workflowId === activeWorkflowId && + (n.action?.type === 'unlock-workflow' || n.message.startsWith('This workflow is locked')) + ) + for (const n of stale) { + store.removeNotification(n.id) + } + }, [activeWorkflowId, clearLockNotification]) + const prevCanAdminRef = useRef(effectivePermissions.canAdmin) useEffect(() => { if (!isWorkflowReady) return - const workflowChanged = prevWorkflowIdRef.current !== activeWorkflowId const canAdminChanged = prevCanAdminRef.current !== effectivePermissions.canAdmin - prevWorkflowIdRef.current = activeWorkflowId prevCanAdminRef.current = effectivePermissions.canAdmin - // Clear stale notification when workflow or admin status changes - if ((workflowChanged || canAdminChanged) && lockNotificationIdRef.current) { + // Clear stale notification when admin status changes so it recreates with correct message + if (canAdminChanged) { clearLockNotification() } From 1f2306ea75a1cb7fb08f021659b44eb785d03112 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 25 Feb 2026 13:54:51 -0800 Subject: [PATCH 04/12] improvement(workflow): memoize hasLockedBlocks to avoid inline recomputation --- .../app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 97c2978b09..451e5a709f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -401,6 +401,8 @@ const WorkflowContent = React.memo(() => { const hasBlocks = useMemo(() => Object.keys(blocks).length > 0, [blocks]) + const hasLockedBlocks = useMemo(() => Object.values(blocks).some((b) => b.locked), [blocks]) + const isWorkflowReady = useMemo( () => hydration.phase === 'ready' && @@ -3792,7 +3794,7 @@ const WorkflowContent = React.memo(() => { disableEdit={!effectivePermissions.canEdit} canUndo={canUndo} canRedo={canRedo} - hasLockedBlocks={Object.values(blocks).some((b) => b.locked)} + hasLockedBlocks={hasLockedBlocks} onToggleWorkflowLock={handleToggleWorkflowLock} allBlocksLocked={allBlocksLocked} canAdmin={effectivePermissions.canAdmin} From 486a76d4e4cc72085a53d424f476a11321401958 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 25 Feb 2026 13:24:22 -0800 Subject: [PATCH 05/12] feat(google-translate): add Google Translate integration (#3337) * feat(google-translate): add Google Translate integration * fix(google-translate): api key as query param, fix docsLink, rename tool file --- apps/docs/components/icons.tsx | 28 ++++ apps/docs/components/ui/icon-mapping.ts | 2 + .../docs/en/tools/google_translate.mdx | 60 ++++++++ apps/docs/content/docs/en/tools/meta.json | 1 + apps/sim/blocks/blocks/google_translate.ts | 130 ++++++++++++++++++ apps/sim/blocks/registry.ts | 2 + apps/sim/components/icons.tsx | 28 ++++ apps/sim/tools/google_translate/detect.ts | 82 +++++++++++ apps/sim/tools/google_translate/index.ts | 4 + apps/sim/tools/google_translate/text.ts | 102 ++++++++++++++ apps/sim/tools/google_translate/types.ts | 28 ++++ apps/sim/tools/registry.ts | 3 + 12 files changed, 470 insertions(+) create mode 100644 apps/docs/content/docs/en/tools/google_translate.mdx create mode 100644 apps/sim/blocks/blocks/google_translate.ts create mode 100644 apps/sim/tools/google_translate/detect.ts create mode 100644 apps/sim/tools/google_translate/index.ts create mode 100644 apps/sim/tools/google_translate/text.ts create mode 100644 apps/sim/tools/google_translate/types.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index dcd5741f2b..91748946d4 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -5445,6 +5445,34 @@ export function GoogleMapsIcon(props: SVGProps) { ) } +export function GoogleTranslateIcon(props: SVGProps) { + return ( + + + + + + + + + ) +} + export function DsPyIcon(props: SVGProps) { return ( diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 5121253240..ba0e2bf32f 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -52,6 +52,7 @@ import { GoogleMapsIcon, GoogleSheetsIcon, GoogleSlidesIcon, + GoogleTranslateIcon, GoogleVaultIcon, GrafanaIcon, GrainIcon, @@ -197,6 +198,7 @@ export const blockTypeToIconMap: Record = { google_search: GoogleIcon, google_sheets_v2: GoogleSheetsIcon, google_slides_v2: GoogleSlidesIcon, + google_translate: GoogleTranslateIcon, google_vault: GoogleVaultIcon, grafana: GrafanaIcon, grain: GrainIcon, diff --git a/apps/docs/content/docs/en/tools/google_translate.mdx b/apps/docs/content/docs/en/tools/google_translate.mdx new file mode 100644 index 0000000000..2e890b1149 --- /dev/null +++ b/apps/docs/content/docs/en/tools/google_translate.mdx @@ -0,0 +1,60 @@ +--- +title: Google Translate +description: Translate text using Google Cloud Translation +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Translate and detect languages using the Google Cloud Translation API. Supports auto-detection of the source language. + + + +## Tools + +### `google_translate_text` + +Translate text between languages using the Google Cloud Translation API. Supports auto-detection of the source language. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Google Cloud API key with Cloud Translation API enabled | +| `text` | string | Yes | The text to translate | +| `target` | string | Yes | Target language code \(e.g., "es", "fr", "de", "ja"\) | +| `source` | string | No | Source language code. If omitted, the API will auto-detect the source language. | +| `format` | string | No | Format of the text: "text" for plain text, "html" for HTML content | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `translatedText` | string | The translated text | +| `detectedSourceLanguage` | string | The detected source language code \(if source was not specified\) | + +### `google_translate_detect` + +Detect the language of text using the Google Cloud Translation API. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Google Cloud API key with Cloud Translation API enabled | +| `text` | string | Yes | The text to detect the language of | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `language` | string | The detected language code \(e.g., "en", "es", "fr"\) | +| `confidence` | number | Confidence score of the detection | + + diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index 9fc1cc577e..cd896c4457 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -47,6 +47,7 @@ "google_search", "google_sheets", "google_slides", + "google_translate", "google_vault", "grafana", "grain", diff --git a/apps/sim/blocks/blocks/google_translate.ts b/apps/sim/blocks/blocks/google_translate.ts new file mode 100644 index 0000000000..881feea0e5 --- /dev/null +++ b/apps/sim/blocks/blocks/google_translate.ts @@ -0,0 +1,130 @@ +import { GoogleTranslateIcon } from '@/components/icons' +import { AuthMode, type BlockConfig } from '@/blocks/types' + +export const GoogleTranslateBlock: BlockConfig = { + type: 'google_translate', + name: 'Google Translate', + description: 'Translate text using Google Cloud Translation', + longDescription: + 'Translate and detect languages using the Google Cloud Translation API. Supports auto-detection of the source language.', + docsLink: 'https://docs.sim.ai/tools/google_translate', + category: 'tools', + bgColor: '#E0E0E0', + icon: GoogleTranslateIcon, + authMode: AuthMode.ApiKey, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Translate Text', id: 'text' }, + { label: 'Detect Language', id: 'detect' }, + ], + value: () => 'text', + }, + { + id: 'text', + title: 'Text', + type: 'long-input', + placeholder: 'Enter text...', + required: true, + }, + { + id: 'target', + title: 'Target Language', + type: 'dropdown', + condition: { field: 'operation', value: 'text' }, + options: [ + { label: 'English', id: 'en' }, + { label: 'Spanish', id: 'es' }, + { label: 'French', id: 'fr' }, + { label: 'German', id: 'de' }, + { label: 'Italian', id: 'it' }, + { label: 'Portuguese', id: 'pt' }, + { label: 'Russian', id: 'ru' }, + { label: 'Japanese', id: 'ja' }, + { label: 'Korean', id: 'ko' }, + { label: 'Chinese (Simplified)', id: 'zh-CN' }, + { label: 'Chinese (Traditional)', id: 'zh-TW' }, + { label: 'Arabic', id: 'ar' }, + { label: 'Hindi', id: 'hi' }, + { label: 'Turkish', id: 'tr' }, + { label: 'Dutch', id: 'nl' }, + { label: 'Polish', id: 'pl' }, + { label: 'Swedish', id: 'sv' }, + { label: 'Thai', id: 'th' }, + { label: 'Vietnamese', id: 'vi' }, + { label: 'Indonesian', id: 'id' }, + { label: 'Ukrainian', id: 'uk' }, + { label: 'Czech', id: 'cs' }, + { label: 'Greek', id: 'el' }, + { label: 'Hebrew', id: 'he' }, + { label: 'Romanian', id: 'ro' }, + { label: 'Hungarian', id: 'hu' }, + { label: 'Danish', id: 'da' }, + { label: 'Finnish', id: 'fi' }, + { label: 'Norwegian', id: 'no' }, + { label: 'Bengali', id: 'bn' }, + { label: 'Malay', id: 'ms' }, + { label: 'Filipino', id: 'tl' }, + { label: 'Swahili', id: 'sw' }, + { label: 'Urdu', id: 'ur' }, + ], + value: () => 'es', + required: { field: 'operation', value: 'text' }, + }, + { + id: 'source', + title: 'Source Language', + type: 'dropdown', + condition: { field: 'operation', value: 'text' }, + options: [ + { label: 'Auto-detect', id: '' }, + { label: 'English', id: 'en' }, + { label: 'Spanish', id: 'es' }, + { label: 'French', id: 'fr' }, + { label: 'German', id: 'de' }, + { label: 'Italian', id: 'it' }, + { label: 'Portuguese', id: 'pt' }, + { label: 'Russian', id: 'ru' }, + { label: 'Japanese', id: 'ja' }, + { label: 'Korean', id: 'ko' }, + { label: 'Chinese (Simplified)', id: 'zh-CN' }, + { label: 'Chinese (Traditional)', id: 'zh-TW' }, + { label: 'Arabic', id: 'ar' }, + { label: 'Hindi', id: 'hi' }, + { label: 'Turkish', id: 'tr' }, + { label: 'Dutch', id: 'nl' }, + { label: 'Polish', id: 'pl' }, + ], + value: () => '', + }, + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'Enter your Google Cloud API key', + password: true, + required: true, + }, + ], + tools: { + access: ['google_translate_text', 'google_translate_detect'], + config: { + tool: (params) => `google_translate_${params.operation}`, + }, + }, + inputs: { + text: { type: 'string', description: 'Text to translate or detect language of' }, + target: { type: 'string', description: 'Target language code' }, + source: { type: 'string', description: 'Source language code (optional, auto-detected)' }, + apiKey: { type: 'string', description: 'Google Cloud API key' }, + }, + outputs: { + translatedText: { type: 'string', description: 'Translated text' }, + detectedSourceLanguage: { type: 'string', description: 'Detected source language code' }, + language: { type: 'string', description: 'Detected language code' }, + confidence: { type: 'number', description: 'Detection confidence score' }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 03b9827a77..a6d3ef3652 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -52,6 +52,7 @@ import { GoogleGroupsBlock } from '@/blocks/blocks/google_groups' import { GoogleMapsBlock } from '@/blocks/blocks/google_maps' import { GoogleSheetsBlock, GoogleSheetsV2Block } from '@/blocks/blocks/google_sheets' import { GoogleSlidesBlock, GoogleSlidesV2Block } from '@/blocks/blocks/google_slides' +import { GoogleTranslateBlock } from '@/blocks/blocks/google_translate' import { GoogleVaultBlock } from '@/blocks/blocks/google_vault' import { GrafanaBlock } from '@/blocks/blocks/grafana' import { GrainBlock } from '@/blocks/blocks/grain' @@ -234,6 +235,7 @@ export const registry: Record = { google_forms: GoogleFormsBlock, google_groups: GoogleGroupsBlock, google_maps: GoogleMapsBlock, + google_translate: GoogleTranslateBlock, gong: GongBlock, google_search: GoogleSearchBlock, google_sheets: GoogleSheetsBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index dcd5741f2b..91748946d4 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -5445,6 +5445,34 @@ export function GoogleMapsIcon(props: SVGProps) { ) } +export function GoogleTranslateIcon(props: SVGProps) { + return ( + + + + + + + + + ) +} + export function DsPyIcon(props: SVGProps) { return ( diff --git a/apps/sim/tools/google_translate/detect.ts b/apps/sim/tools/google_translate/detect.ts new file mode 100644 index 0000000000..48941276f3 --- /dev/null +++ b/apps/sim/tools/google_translate/detect.ts @@ -0,0 +1,82 @@ +import type { + GoogleTranslateDetectParams, + GoogleTranslateDetectResponse, +} from '@/tools/google_translate/types' +import type { ToolConfig } from '@/tools/types' + +export const googleTranslateDetectTool: ToolConfig< + GoogleTranslateDetectParams, + GoogleTranslateDetectResponse +> = { + id: 'google_translate_detect', + name: 'Google Translate Detect Language', + description: 'Detect the language of text using the Google Cloud Translation API.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Google Cloud API key with Cloud Translation API enabled', + }, + text: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The text to detect the language of', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://translation.googleapis.com/language/translate/v2/detect') + url.searchParams.set('key', params.apiKey) + return url.toString() + }, + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + q: params.text, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (data.error) { + return { + success: false, + output: { + language: '', + confidence: null, + }, + error: data.error.message ?? 'Google Translate API error', + } + } + + const detection = data.data?.detections?.[0]?.[0] + + return { + success: true, + output: { + language: detection?.language ?? '', + confidence: detection?.confidence ?? null, + }, + } + }, + + outputs: { + language: { + type: 'string', + description: 'The detected language code (e.g., "en", "es", "fr")', + }, + confidence: { + type: 'number', + description: 'Confidence score of the detection', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/google_translate/index.ts b/apps/sim/tools/google_translate/index.ts new file mode 100644 index 0000000000..ddc89124f7 --- /dev/null +++ b/apps/sim/tools/google_translate/index.ts @@ -0,0 +1,4 @@ +import { googleTranslateDetectTool } from './detect' +import { googleTranslateTool } from './text' + +export { googleTranslateDetectTool, googleTranslateTool } diff --git a/apps/sim/tools/google_translate/text.ts b/apps/sim/tools/google_translate/text.ts new file mode 100644 index 0000000000..8f74df318c --- /dev/null +++ b/apps/sim/tools/google_translate/text.ts @@ -0,0 +1,102 @@ +import type { GoogleTranslateParams, GoogleTranslateResponse } from '@/tools/google_translate/types' +import type { ToolConfig } from '@/tools/types' + +export const googleTranslateTool: ToolConfig = { + id: 'google_translate_text', + name: 'Google Translate', + description: + 'Translate text between languages using the Google Cloud Translation API. Supports auto-detection of the source language.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Google Cloud API key with Cloud Translation API enabled', + }, + text: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The text to translate', + }, + target: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Target language code (e.g., "es", "fr", "de", "ja")', + }, + source: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Source language code. If omitted, the API will auto-detect the source language.', + }, + format: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Format of the text: "text" for plain text, "html" for HTML content', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://translation.googleapis.com/language/translate/v2') + url.searchParams.set('key', params.apiKey) + return url.toString() + }, + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { + q: params.text, + target: params.target, + } + if (params.source) body.source = params.source + if (params.format) body.format = params.format + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (data.error) { + return { + success: false, + output: { + translatedText: '', + detectedSourceLanguage: null, + }, + error: data.error.message ?? 'Google Translate API error', + } + } + + const translation = data.data?.translations?.[0] + + return { + success: true, + output: { + translatedText: translation?.translatedText ?? '', + detectedSourceLanguage: translation?.detectedSourceLanguage ?? null, + }, + } + }, + + outputs: { + translatedText: { + type: 'string', + description: 'The translated text', + }, + detectedSourceLanguage: { + type: 'string', + description: 'The detected source language code (if source was not specified)', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/google_translate/types.ts b/apps/sim/tools/google_translate/types.ts new file mode 100644 index 0000000000..2ceffd18f5 --- /dev/null +++ b/apps/sim/tools/google_translate/types.ts @@ -0,0 +1,28 @@ +import type { ToolResponse } from '@/tools/types' + +export interface GoogleTranslateParams { + apiKey: string + text: string + target: string + source?: string + format?: 'text' | 'html' +} + +export interface GoogleTranslateResponse extends ToolResponse { + output: { + translatedText: string + detectedSourceLanguage: string | null + } +} + +export interface GoogleTranslateDetectParams { + apiKey: string + text: string +} + +export interface GoogleTranslateDetectResponse extends ToolResponse { + output: { + language: string + confidence: number | null + } +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 5a2f5787c7..ab49e04850 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -738,6 +738,7 @@ import { googleSlidesUpdateSlidesPositionTool, googleSlidesWriteTool, } from '@/tools/google_slides' +import { googleTranslateDetectTool, googleTranslateTool } from '@/tools/google_translate' import { createMattersExportTool, createMattersHoldsTool, @@ -2891,6 +2892,8 @@ export const tools: Record = { google_maps_speed_limits: googleMapsSpeedLimitsTool, google_maps_timezone: googleMapsTimezoneTool, google_maps_validate_address: googleMapsValidateAddressTool, + google_translate_detect: googleTranslateDetectTool, + google_translate_text: googleTranslateTool, google_sheets_read: googleSheetsReadTool, google_sheets_write: googleSheetsWriteTool, google_sheets_update: googleSheetsUpdateTool, From 2f72d801945f42b7d06ca23319cc94b794e456ac Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 25 Feb 2026 13:38:35 -0800 Subject: [PATCH 06/12] feat(google): add missing tools for Gmail, Drive, Sheets, and Calendar (#3338) * feat(google): add missing tools for Gmail, Drive, Sheets, and Calendar * fix(google-drive): remove dead transformResponse from move tool --- apps/sim/tools/gmail/create_label.ts | 119 +++++++++++++ apps/sim/tools/gmail/delete_draft.ts | 76 +++++++++ apps/sim/tools/gmail/delete_label.ts | 76 +++++++++ apps/sim/tools/gmail/get_draft.ts | 117 +++++++++++++ apps/sim/tools/gmail/get_thread.ts | 136 +++++++++++++++ apps/sim/tools/gmail/index.ts | 20 +++ apps/sim/tools/gmail/list_drafts.ts | 126 ++++++++++++++ apps/sim/tools/gmail/list_labels.ts | 81 +++++++++ apps/sim/tools/gmail/list_threads.ts | 140 ++++++++++++++++ apps/sim/tools/gmail/trash_thread.ts | 78 +++++++++ apps/sim/tools/gmail/untrash_thread.ts | 84 ++++++++++ apps/sim/tools/google_calendar/freebusy.ts | 155 +++++++++++++++++ apps/sim/tools/google_calendar/index.ts | 3 + apps/sim/tools/google_calendar/types.ts | 40 +++++ apps/sim/tools/google_drive/index.ts | 4 + apps/sim/tools/google_drive/move.ts | 145 ++++++++++++++++ apps/sim/tools/google_drive/search.ts | 145 ++++++++++++++++ apps/sim/tools/google_sheets/delete_rows.ts | 156 ++++++++++++++++++ apps/sim/tools/google_sheets/delete_sheet.ts | 123 ++++++++++++++ .../tools/google_sheets/delete_spreadsheet.ts | 86 ++++++++++ apps/sim/tools/google_sheets/index.ts | 6 + apps/sim/tools/google_sheets/types.ts | 6 + apps/sim/tools/registry.ts | 34 ++++ 23 files changed, 1956 insertions(+) create mode 100644 apps/sim/tools/gmail/create_label.ts create mode 100644 apps/sim/tools/gmail/delete_draft.ts create mode 100644 apps/sim/tools/gmail/delete_label.ts create mode 100644 apps/sim/tools/gmail/get_draft.ts create mode 100644 apps/sim/tools/gmail/get_thread.ts create mode 100644 apps/sim/tools/gmail/list_drafts.ts create mode 100644 apps/sim/tools/gmail/list_labels.ts create mode 100644 apps/sim/tools/gmail/list_threads.ts create mode 100644 apps/sim/tools/gmail/trash_thread.ts create mode 100644 apps/sim/tools/gmail/untrash_thread.ts create mode 100644 apps/sim/tools/google_calendar/freebusy.ts create mode 100644 apps/sim/tools/google_drive/move.ts create mode 100644 apps/sim/tools/google_drive/search.ts create mode 100644 apps/sim/tools/google_sheets/delete_rows.ts create mode 100644 apps/sim/tools/google_sheets/delete_sheet.ts create mode 100644 apps/sim/tools/google_sheets/delete_spreadsheet.ts diff --git a/apps/sim/tools/gmail/create_label.ts b/apps/sim/tools/gmail/create_label.ts new file mode 100644 index 0000000000..98d9c4e447 --- /dev/null +++ b/apps/sim/tools/gmail/create_label.ts @@ -0,0 +1,119 @@ +import { GMAIL_API_BASE } from '@/tools/gmail/utils' +import type { ToolConfig } from '@/tools/types' + +interface GmailCreateLabelParams { + accessToken: string + name: string + messageListVisibility?: string + labelListVisibility?: string +} + +interface GmailCreateLabelResponse { + success: boolean + output: { + id: string + name: string + messageListVisibility?: string + labelListVisibility?: string + type?: string + } +} + +export const gmailCreateLabelV2Tool: ToolConfig = + { + id: 'gmail_create_label_v2', + name: 'Gmail Create Label', + description: 'Create a new label in Gmail', + version: '2.0.0', + + oauth: { + required: true, + provider: 'google-email', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Gmail API', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Display name for the new label', + }, + messageListVisibility: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Visibility of messages with this label in the message list (show or hide)', + }, + labelListVisibility: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Visibility of the label in the label list (labelShow, labelShowIfUnread, or labelHide)', + }, + }, + + request: { + url: () => `${GMAIL_API_BASE}/labels`, + method: 'POST', + headers: (params: GmailCreateLabelParams) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + body: (params: GmailCreateLabelParams) => { + const body: Record = { name: params.name } + if (params.messageListVisibility) { + body.messageListVisibility = params.messageListVisibility + } + if (params.labelListVisibility) { + body.labelListVisibility = params.labelListVisibility + } + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + return { + success: false, + output: { id: '', name: '' }, + error: data.error?.message || 'Failed to create label', + } + } + + return { + success: true, + output: { + id: data.id, + name: data.name, + messageListVisibility: data.messageListVisibility ?? null, + labelListVisibility: data.labelListVisibility ?? null, + type: data.type ?? null, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Label ID' }, + name: { type: 'string', description: 'Label display name' }, + messageListVisibility: { + type: 'string', + description: 'Visibility of messages with this label', + optional: true, + }, + labelListVisibility: { + type: 'string', + description: 'Visibility of the label in the label list', + optional: true, + }, + type: { type: 'string', description: 'Label type (system or user)', optional: true }, + }, + } diff --git a/apps/sim/tools/gmail/delete_draft.ts b/apps/sim/tools/gmail/delete_draft.ts new file mode 100644 index 0000000000..9597931542 --- /dev/null +++ b/apps/sim/tools/gmail/delete_draft.ts @@ -0,0 +1,76 @@ +import { GMAIL_API_BASE } from '@/tools/gmail/utils' +import type { ToolConfig } from '@/tools/types' + +interface GmailDeleteDraftParams { + accessToken: string + draftId: string +} + +interface GmailDeleteDraftResponse { + success: boolean + output: { + deleted: boolean + draftId: string + } +} + +export const gmailDeleteDraftV2Tool: ToolConfig = + { + id: 'gmail_delete_draft_v2', + name: 'Gmail Delete Draft', + description: 'Delete a specific draft from Gmail', + version: '2.0.0', + + oauth: { + required: true, + provider: 'google-email', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Gmail API', + }, + draftId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the draft to delete', + }, + }, + + request: { + url: (params: GmailDeleteDraftParams) => `${GMAIL_API_BASE}/drafts/${params.draftId}`, + method: 'DELETE', + headers: (params: GmailDeleteDraftParams) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response: Response, params?: GmailDeleteDraftParams) => { + if (!response.ok) { + const data = await response.json() + return { + success: false, + output: { deleted: false, draftId: params?.draftId ?? '' }, + error: data.error?.message || 'Failed to delete draft', + } + } + + return { + success: true, + output: { + deleted: true, + draftId: params?.draftId ?? '', + }, + } + }, + + outputs: { + deleted: { type: 'boolean', description: 'Whether the draft was successfully deleted' }, + draftId: { type: 'string', description: 'ID of the deleted draft' }, + }, + } diff --git a/apps/sim/tools/gmail/delete_label.ts b/apps/sim/tools/gmail/delete_label.ts new file mode 100644 index 0000000000..2ecf1e76ad --- /dev/null +++ b/apps/sim/tools/gmail/delete_label.ts @@ -0,0 +1,76 @@ +import { GMAIL_API_BASE } from '@/tools/gmail/utils' +import type { ToolConfig } from '@/tools/types' + +interface GmailDeleteLabelParams { + accessToken: string + labelId: string +} + +interface GmailDeleteLabelResponse { + success: boolean + output: { + deleted: boolean + labelId: string + } +} + +export const gmailDeleteLabelV2Tool: ToolConfig = + { + id: 'gmail_delete_label_v2', + name: 'Gmail Delete Label', + description: 'Delete a label from Gmail', + version: '2.0.0', + + oauth: { + required: true, + provider: 'google-email', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Gmail API', + }, + labelId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the label to delete', + }, + }, + + request: { + url: (params: GmailDeleteLabelParams) => `${GMAIL_API_BASE}/labels/${params.labelId}`, + method: 'DELETE', + headers: (params: GmailDeleteLabelParams) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response: Response, params?: GmailDeleteLabelParams) => { + if (!response.ok) { + const data = await response.json() + return { + success: false, + output: { deleted: false, labelId: params?.labelId ?? '' }, + error: data.error?.message || 'Failed to delete label', + } + } + + return { + success: true, + output: { + deleted: true, + labelId: params?.labelId ?? '', + }, + } + }, + + outputs: { + deleted: { type: 'boolean', description: 'Whether the label was successfully deleted' }, + labelId: { type: 'string', description: 'ID of the deleted label' }, + }, + } diff --git a/apps/sim/tools/gmail/get_draft.ts b/apps/sim/tools/gmail/get_draft.ts new file mode 100644 index 0000000000..932f0733c7 --- /dev/null +++ b/apps/sim/tools/gmail/get_draft.ts @@ -0,0 +1,117 @@ +import { GMAIL_API_BASE } from '@/tools/gmail/utils' +import type { ToolConfig } from '@/tools/types' + +interface GmailGetDraftParams { + accessToken: string + draftId: string +} + +interface GmailGetDraftResponse { + success: boolean + output: { + id: string + messageId?: string + threadId?: string + to?: string + from?: string + subject?: string + body?: string + labelIds?: string[] + } +} + +export const gmailGetDraftV2Tool: ToolConfig = { + id: 'gmail_get_draft_v2', + name: 'Gmail Get Draft', + description: 'Get a specific draft from Gmail by its ID', + version: '2.0.0', + + oauth: { + required: true, + provider: 'google-email', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Gmail API', + }, + draftId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the draft to retrieve', + }, + }, + + request: { + url: (params: GmailGetDraftParams) => `${GMAIL_API_BASE}/drafts/${params.draftId}?format=full`, + method: 'GET', + headers: (params: GmailGetDraftParams) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + return { + success: false, + output: { id: '' }, + error: data.error?.message || 'Failed to get draft', + } + } + + const message = data.message || {} + const headers = message.payload?.headers || [] + const getHeader = (name: string): string | undefined => + headers.find((h: Record) => h.name.toLowerCase() === name.toLowerCase()) + ?.value + + let body = '' + if (message.payload?.body?.data) { + body = Buffer.from(message.payload.body.data, 'base64').toString() + } else if (message.payload?.parts) { + const textPart = message.payload.parts.find( + (part: Record) => part.mimeType === 'text/plain' + ) + if (textPart?.body?.data) { + body = Buffer.from(textPart.body.data, 'base64').toString() + } + } + + return { + success: true, + output: { + id: data.id, + messageId: message.id ?? undefined, + threadId: message.threadId ?? undefined, + to: getHeader('To'), + from: getHeader('From'), + subject: getHeader('Subject'), + body: body || undefined, + labelIds: message.labelIds ?? undefined, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Draft ID' }, + messageId: { type: 'string', description: 'Gmail message ID', optional: true }, + threadId: { type: 'string', description: 'Gmail thread ID', optional: true }, + to: { type: 'string', description: 'Recipient email address', optional: true }, + from: { type: 'string', description: 'Sender email address', optional: true }, + subject: { type: 'string', description: 'Draft subject', optional: true }, + body: { type: 'string', description: 'Draft body text', optional: true }, + labelIds: { + type: 'array', + items: { type: 'string' }, + description: 'Draft labels', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/gmail/get_thread.ts b/apps/sim/tools/gmail/get_thread.ts new file mode 100644 index 0000000000..33641298ce --- /dev/null +++ b/apps/sim/tools/gmail/get_thread.ts @@ -0,0 +1,136 @@ +import { GMAIL_API_BASE } from '@/tools/gmail/utils' +import type { ToolConfig } from '@/tools/types' + +interface GmailGetThreadParams { + accessToken: string + threadId: string + format?: string +} + +interface GmailGetThreadResponse { + success: boolean + output: { + id: string + historyId?: string + messages: Array<{ + id: string + threadId: string + labelIds?: string[] + snippet?: string + from?: string + to?: string + subject?: string + date?: string + body?: string + }> + } +} + +export const gmailGetThreadV2Tool: ToolConfig = { + id: 'gmail_get_thread_v2', + name: 'Gmail Get Thread', + description: 'Get a specific email thread from Gmail, including all messages in the thread', + version: '2.0.0', + + oauth: { + required: true, + provider: 'google-email', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Gmail API', + }, + threadId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the thread to retrieve', + }, + format: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Format to return the messages in (full, metadata, or minimal). Defaults to full.', + }, + }, + + request: { + url: (params: GmailGetThreadParams) => { + const format = params.format || 'full' + return `${GMAIL_API_BASE}/threads/${params.threadId}?format=${format}` + }, + method: 'GET', + headers: (params: GmailGetThreadParams) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + return { + success: false, + output: { id: '', messages: [] }, + error: data.error?.message || 'Failed to get thread', + } + } + + const messages = (data.messages || []).map((message: Record) => { + const payload = message.payload as Record | undefined + const headers = (payload?.headers as Array>) || [] + const getHeader = (name: string): string | undefined => + headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value + + let body = '' + const payloadBody = payload?.body as Record | undefined + if (payloadBody?.data) { + body = Buffer.from(payloadBody.data as string, 'base64').toString() + } else if (payload?.parts) { + const parts = payload.parts as Array> + const textPart = parts.find((part) => part.mimeType === 'text/plain') + const textBody = textPart?.body as Record | undefined + if (textBody?.data) { + body = Buffer.from(textBody.data as string, 'base64').toString() + } + } + + return { + id: message.id, + threadId: message.threadId, + labelIds: message.labelIds ?? null, + snippet: message.snippet ?? null, + from: getHeader('From') ?? null, + to: getHeader('To') ?? null, + subject: getHeader('Subject') ?? null, + date: getHeader('Date') ?? null, + body: body || null, + } + }) + + return { + success: true, + output: { + id: data.id, + historyId: data.historyId ?? null, + messages, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Thread ID' }, + historyId: { type: 'string', description: 'History ID', optional: true }, + messages: { + type: 'json', + description: + 'Array of messages in the thread with id, from, to, subject, date, body, and labels', + }, + }, +} diff --git a/apps/sim/tools/gmail/index.ts b/apps/sim/tools/gmail/index.ts index 3dfa89e4eb..6869a2e5f6 100644 --- a/apps/sim/tools/gmail/index.ts +++ b/apps/sim/tools/gmail/index.ts @@ -1,7 +1,15 @@ import { gmailAddLabelTool, gmailAddLabelV2Tool } from '@/tools/gmail/add_label' import { gmailArchiveTool, gmailArchiveV2Tool } from '@/tools/gmail/archive' +import { gmailCreateLabelV2Tool } from '@/tools/gmail/create_label' import { gmailDeleteTool, gmailDeleteV2Tool } from '@/tools/gmail/delete' +import { gmailDeleteDraftV2Tool } from '@/tools/gmail/delete_draft' +import { gmailDeleteLabelV2Tool } from '@/tools/gmail/delete_label' import { gmailDraftTool, gmailDraftV2Tool } from '@/tools/gmail/draft' +import { gmailGetDraftV2Tool } from '@/tools/gmail/get_draft' +import { gmailGetThreadV2Tool } from '@/tools/gmail/get_thread' +import { gmailListDraftsV2Tool } from '@/tools/gmail/list_drafts' +import { gmailListLabelsV2Tool } from '@/tools/gmail/list_labels' +import { gmailListThreadsV2Tool } from '@/tools/gmail/list_threads' import { gmailMarkReadTool, gmailMarkReadV2Tool } from '@/tools/gmail/mark_read' import { gmailMarkUnreadTool, gmailMarkUnreadV2Tool } from '@/tools/gmail/mark_unread' import { gmailMoveTool, gmailMoveV2Tool } from '@/tools/gmail/move' @@ -9,7 +17,9 @@ import { gmailReadTool, gmailReadV2Tool } from '@/tools/gmail/read' import { gmailRemoveLabelTool, gmailRemoveLabelV2Tool } from '@/tools/gmail/remove_label' import { gmailSearchTool, gmailSearchV2Tool } from '@/tools/gmail/search' import { gmailSendTool, gmailSendV2Tool } from '@/tools/gmail/send' +import { gmailTrashThreadV2Tool } from '@/tools/gmail/trash_thread' import { gmailUnarchiveTool, gmailUnarchiveV2Tool } from '@/tools/gmail/unarchive' +import { gmailUntrashThreadV2Tool } from '@/tools/gmail/untrash_thread' export { gmailSendTool, @@ -36,4 +46,14 @@ export { gmailAddLabelV2Tool, gmailRemoveLabelTool, gmailRemoveLabelV2Tool, + gmailListDraftsV2Tool, + gmailGetDraftV2Tool, + gmailDeleteDraftV2Tool, + gmailCreateLabelV2Tool, + gmailDeleteLabelV2Tool, + gmailListLabelsV2Tool, + gmailGetThreadV2Tool, + gmailListThreadsV2Tool, + gmailTrashThreadV2Tool, + gmailUntrashThreadV2Tool, } diff --git a/apps/sim/tools/gmail/list_drafts.ts b/apps/sim/tools/gmail/list_drafts.ts new file mode 100644 index 0000000000..5ba31e5a23 --- /dev/null +++ b/apps/sim/tools/gmail/list_drafts.ts @@ -0,0 +1,126 @@ +import { GMAIL_API_BASE } from '@/tools/gmail/utils' +import type { ToolConfig } from '@/tools/types' + +interface GmailListDraftsParams { + accessToken: string + maxResults?: number + pageToken?: string + query?: string +} + +interface GmailListDraftsResponse { + success: boolean + output: { + drafts: Array<{ + id: string + messageId: string + threadId: string + }> + resultSizeEstimate: number + nextPageToken?: string + } +} + +export const gmailListDraftsV2Tool: ToolConfig = { + id: 'gmail_list_drafts_v2', + name: 'Gmail List Drafts', + description: 'List all drafts in a Gmail account', + version: '2.0.0', + + oauth: { + required: true, + provider: 'google-email', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Gmail API', + }, + maxResults: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of drafts to return (default: 100, max: 500)', + }, + pageToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page token for paginated results', + }, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Search query to filter drafts (same syntax as Gmail search)', + }, + }, + + request: { + url: (params: GmailListDraftsParams) => { + const searchParams = new URLSearchParams() + if (params.maxResults) { + searchParams.append('maxResults', Number(params.maxResults).toString()) + } + if (params.pageToken) { + searchParams.append('pageToken', params.pageToken) + } + if (params.query) { + searchParams.append('q', params.query) + } + const qs = searchParams.toString() + return `${GMAIL_API_BASE}/drafts${qs ? `?${qs}` : ''}` + }, + method: 'GET', + headers: (params: GmailListDraftsParams) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + return { + success: false, + output: { drafts: [], resultSizeEstimate: 0 }, + error: data.error?.message || 'Failed to list drafts', + } + } + + const drafts = (data.drafts || []).map((draft: Record) => ({ + id: draft.id, + messageId: (draft.message as Record)?.id ?? null, + threadId: (draft.message as Record)?.threadId ?? null, + })) + + return { + success: true, + output: { + drafts, + resultSizeEstimate: data.resultSizeEstimate ?? 0, + nextPageToken: data.nextPageToken ?? null, + }, + } + }, + + outputs: { + drafts: { + type: 'json', + description: 'Array of draft objects with id, messageId, and threadId', + }, + resultSizeEstimate: { + type: 'number', + description: 'Estimated total number of drafts', + }, + nextPageToken: { + type: 'string', + description: 'Token for fetching the next page of results', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/gmail/list_labels.ts b/apps/sim/tools/gmail/list_labels.ts new file mode 100644 index 0000000000..159af96f34 --- /dev/null +++ b/apps/sim/tools/gmail/list_labels.ts @@ -0,0 +1,81 @@ +import { GMAIL_API_BASE } from '@/tools/gmail/utils' +import type { ToolConfig } from '@/tools/types' + +interface GmailListLabelsParams { + accessToken: string +} + +interface GmailListLabelsResponse { + success: boolean + output: { + labels: Array<{ + id: string + name: string + type: string + messageListVisibility?: string + labelListVisibility?: string + }> + } +} + +export const gmailListLabelsV2Tool: ToolConfig = { + id: 'gmail_list_labels_v2', + name: 'Gmail List Labels', + description: 'List all labels in a Gmail account', + version: '2.0.0', + + oauth: { + required: true, + provider: 'google-email', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Gmail API', + }, + }, + + request: { + url: () => `${GMAIL_API_BASE}/labels`, + method: 'GET', + headers: (params: GmailListLabelsParams) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + return { + success: false, + output: { labels: [] }, + error: data.error?.message || 'Failed to list labels', + } + } + + const labels = (data.labels || []).map((label: Record) => ({ + id: label.id, + name: label.name, + type: label.type ?? null, + messageListVisibility: label.messageListVisibility ?? null, + labelListVisibility: label.labelListVisibility ?? null, + })) + + return { + success: true, + output: { labels }, + } + }, + + outputs: { + labels: { + type: 'json', + description: 'Array of label objects with id, name, type, and visibility settings', + }, + }, +} diff --git a/apps/sim/tools/gmail/list_threads.ts b/apps/sim/tools/gmail/list_threads.ts new file mode 100644 index 0000000000..a2617cf9c5 --- /dev/null +++ b/apps/sim/tools/gmail/list_threads.ts @@ -0,0 +1,140 @@ +import { GMAIL_API_BASE } from '@/tools/gmail/utils' +import type { ToolConfig } from '@/tools/types' + +interface GmailListThreadsParams { + accessToken: string + maxResults?: number + pageToken?: string + query?: string + labelIds?: string +} + +interface GmailListThreadsResponse { + success: boolean + output: { + threads: Array<{ + id: string + snippet: string + historyId: string + }> + resultSizeEstimate: number + nextPageToken?: string + } +} + +export const gmailListThreadsV2Tool: ToolConfig = + { + id: 'gmail_list_threads_v2', + name: 'Gmail List Threads', + description: 'List email threads in a Gmail account', + version: '2.0.0', + + oauth: { + required: true, + provider: 'google-email', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Gmail API', + }, + maxResults: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of threads to return (default: 100, max: 500)', + }, + pageToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page token for paginated results', + }, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Search query to filter threads (same syntax as Gmail search)', + }, + labelIds: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated label IDs to filter threads by', + }, + }, + + request: { + url: (params: GmailListThreadsParams) => { + const searchParams = new URLSearchParams() + if (params.maxResults) { + searchParams.append('maxResults', Number(params.maxResults).toString()) + } + if (params.pageToken) { + searchParams.append('pageToken', params.pageToken) + } + if (params.query) { + searchParams.append('q', params.query) + } + if (params.labelIds) { + const labels = params.labelIds.split(',').map((l) => l.trim()) + for (const label of labels) { + searchParams.append('labelIds', label) + } + } + const qs = searchParams.toString() + return `${GMAIL_API_BASE}/threads${qs ? `?${qs}` : ''}` + }, + method: 'GET', + headers: (params: GmailListThreadsParams) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + return { + success: false, + output: { threads: [], resultSizeEstimate: 0 }, + error: data.error?.message || 'Failed to list threads', + } + } + + const threads = (data.threads || []).map((thread: Record) => ({ + id: thread.id, + snippet: thread.snippet ?? '', + historyId: thread.historyId ?? '', + })) + + return { + success: true, + output: { + threads, + resultSizeEstimate: data.resultSizeEstimate ?? 0, + nextPageToken: data.nextPageToken ?? null, + }, + } + }, + + outputs: { + threads: { + type: 'json', + description: 'Array of thread objects with id, snippet, and historyId', + }, + resultSizeEstimate: { + type: 'number', + description: 'Estimated total number of threads', + }, + nextPageToken: { + type: 'string', + description: 'Token for fetching the next page of results', + optional: true, + }, + }, + } diff --git a/apps/sim/tools/gmail/trash_thread.ts b/apps/sim/tools/gmail/trash_thread.ts new file mode 100644 index 0000000000..4d13c32090 --- /dev/null +++ b/apps/sim/tools/gmail/trash_thread.ts @@ -0,0 +1,78 @@ +import { GMAIL_API_BASE } from '@/tools/gmail/utils' +import type { ToolConfig } from '@/tools/types' + +interface GmailTrashThreadParams { + accessToken: string + threadId: string +} + +interface GmailTrashThreadResponse { + success: boolean + output: { + id: string + trashed: boolean + } +} + +export const gmailTrashThreadV2Tool: ToolConfig = + { + id: 'gmail_trash_thread_v2', + name: 'Gmail Trash Thread', + description: 'Move an email thread to trash in Gmail', + version: '2.0.0', + + oauth: { + required: true, + provider: 'google-email', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Gmail API', + }, + threadId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the thread to trash', + }, + }, + + request: { + url: (params: GmailTrashThreadParams) => `${GMAIL_API_BASE}/threads/${params.threadId}/trash`, + method: 'POST', + headers: (params: GmailTrashThreadParams) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + body: () => ({}), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + return { + success: false, + output: { id: '', trashed: false }, + error: data.error?.message || 'Failed to trash thread', + } + } + + return { + success: true, + output: { + id: data.id, + trashed: true, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Thread ID' }, + trashed: { type: 'boolean', description: 'Whether the thread was successfully trashed' }, + }, + } diff --git a/apps/sim/tools/gmail/untrash_thread.ts b/apps/sim/tools/gmail/untrash_thread.ts new file mode 100644 index 0000000000..ea88aac7c4 --- /dev/null +++ b/apps/sim/tools/gmail/untrash_thread.ts @@ -0,0 +1,84 @@ +import { GMAIL_API_BASE } from '@/tools/gmail/utils' +import type { ToolConfig } from '@/tools/types' + +interface GmailUntrashThreadParams { + accessToken: string + threadId: string +} + +interface GmailUntrashThreadResponse { + success: boolean + output: { + id: string + untrashed: boolean + } +} + +export const gmailUntrashThreadV2Tool: ToolConfig< + GmailUntrashThreadParams, + GmailUntrashThreadResponse +> = { + id: 'gmail_untrash_thread_v2', + name: 'Gmail Untrash Thread', + description: 'Remove an email thread from trash in Gmail', + version: '2.0.0', + + oauth: { + required: true, + provider: 'google-email', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Gmail API', + }, + threadId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the thread to untrash', + }, + }, + + request: { + url: (params: GmailUntrashThreadParams) => + `${GMAIL_API_BASE}/threads/${params.threadId}/untrash`, + method: 'POST', + headers: (params: GmailUntrashThreadParams) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + body: () => ({}), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + return { + success: false, + output: { id: '', untrashed: false }, + error: data.error?.message || 'Failed to untrash thread', + } + } + + return { + success: true, + output: { + id: data.id, + untrashed: true, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Thread ID' }, + untrashed: { + type: 'boolean', + description: 'Whether the thread was successfully removed from trash', + }, + }, +} diff --git a/apps/sim/tools/google_calendar/freebusy.ts b/apps/sim/tools/google_calendar/freebusy.ts new file mode 100644 index 0000000000..00c68ec180 --- /dev/null +++ b/apps/sim/tools/google_calendar/freebusy.ts @@ -0,0 +1,155 @@ +import { + CALENDAR_API_BASE, + type GoogleCalendarApiFreeBusyResponse, + type GoogleCalendarFreeBusyParams, + type GoogleCalendarFreeBusyResponse, +} from '@/tools/google_calendar/types' +import type { ToolConfig } from '@/tools/types' + +export const freebusyTool: ToolConfig< + GoogleCalendarFreeBusyParams, + GoogleCalendarFreeBusyResponse +> = { + id: 'google_calendar_freebusy', + name: 'Google Calendar Free/Busy', + description: 'Query free/busy information for one or more Google Calendars', + version: '1.0.0', + + oauth: { + required: true, + provider: 'google-calendar', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Google Calendar API', + }, + calendarIds: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comma-separated calendar IDs to query (e.g., "primary,other@example.com")', + }, + timeMin: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Start of the time range (RFC3339 timestamp, e.g., 2025-06-03T00:00:00Z)', + }, + timeMax: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'End of the time range (RFC3339 timestamp, e.g., 2025-06-04T00:00:00Z)', + }, + timeZone: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'IANA time zone (e.g., "UTC", "America/New_York"). Defaults to UTC.', + }, + }, + + request: { + url: () => `${CALENDAR_API_BASE}/freeBusy`, + method: 'POST', + headers: (params: GoogleCalendarFreeBusyParams) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + body: (params: GoogleCalendarFreeBusyParams) => { + const ids = params.calendarIds + .split(',') + .map((id) => id.trim()) + .filter(Boolean) + + return { + timeMin: params.timeMin, + timeMax: params.timeMax, + timeZone: params.timeZone || 'UTC', + items: ids.map((id) => ({ id })), + } + }, + }, + + transformResponse: async (response: Response) => { + const data: GoogleCalendarApiFreeBusyResponse = await response.json() + + const calendarIds = Object.keys(data.calendars || {}) + const totalBusy = calendarIds.reduce((sum, id) => { + return sum + (data.calendars[id]?.busy?.length || 0) + }, 0) + + return { + success: true, + output: { + content: `Found ${totalBusy} busy period${totalBusy !== 1 ? 's' : ''} across ${calendarIds.length} calendar${calendarIds.length !== 1 ? 's' : ''}`, + metadata: { + timeMin: data.timeMin, + timeMax: data.timeMax, + calendars: data.calendars, + }, + }, + } + }, + + outputs: { + content: { type: 'string', description: 'Summary of free/busy results' }, + metadata: { + type: 'json', + description: 'Free/busy data with time range and per-calendar busy periods', + }, + }, +} + +interface GoogleCalendarFreeBusyV2Response { + success: boolean + output: { + timeMin: string + timeMax: string + calendars: Record< + string, + { + busy: Array<{ start: string; end: string }> + errors?: Array<{ domain: string; reason: string }> + } + > + } +} + +export const freebusyV2Tool: ToolConfig< + GoogleCalendarFreeBusyParams, + GoogleCalendarFreeBusyV2Response +> = { + id: 'google_calendar_freebusy_v2', + name: 'Google Calendar Free/Busy', + description: + 'Query free/busy information for one or more Google Calendars. Returns API-aligned fields only.', + version: '2.0.0', + oauth: freebusyTool.oauth, + params: freebusyTool.params, + request: freebusyTool.request, + transformResponse: async (response: Response) => { + const data: GoogleCalendarApiFreeBusyResponse = await response.json() + + return { + success: true, + output: { + timeMin: data.timeMin, + timeMax: data.timeMax, + calendars: data.calendars, + }, + } + }, + outputs: { + timeMin: { type: 'string', description: 'Start of the queried time range' }, + timeMax: { type: 'string', description: 'End of the queried time range' }, + calendars: { + type: 'json', + description: 'Per-calendar free/busy data with busy periods and any errors', + }, + }, +} diff --git a/apps/sim/tools/google_calendar/index.ts b/apps/sim/tools/google_calendar/index.ts index 2ddfcf55e5..4d55e57ad9 100644 --- a/apps/sim/tools/google_calendar/index.ts +++ b/apps/sim/tools/google_calendar/index.ts @@ -1,5 +1,6 @@ import { createTool, createV2Tool } from '@/tools/google_calendar/create' import { deleteTool, deleteV2Tool } from '@/tools/google_calendar/delete' +import { freebusyTool, freebusyV2Tool } from '@/tools/google_calendar/freebusy' import { getTool, getV2Tool } from '@/tools/google_calendar/get' import { instancesTool, instancesV2Tool } from '@/tools/google_calendar/instances' import { inviteTool, inviteV2Tool } from '@/tools/google_calendar/invite' @@ -11,6 +12,7 @@ import { updateTool, updateV2Tool } from '@/tools/google_calendar/update' export const googleCalendarCreateTool = createTool export const googleCalendarDeleteTool = deleteTool +export const googleCalendarFreeBusyTool = freebusyTool export const googleCalendarGetTool = getTool export const googleCalendarInstancesTool = instancesTool export const googleCalendarInviteTool = inviteTool @@ -22,6 +24,7 @@ export const googleCalendarUpdateTool = updateTool export const googleCalendarCreateV2Tool = createV2Tool export const googleCalendarDeleteV2Tool = deleteV2Tool +export const googleCalendarFreeBusyV2Tool = freebusyV2Tool export const googleCalendarGetV2Tool = getV2Tool export const googleCalendarInstancesV2Tool = instancesV2Tool export const googleCalendarInviteV2Tool = inviteV2Tool diff --git a/apps/sim/tools/google_calendar/types.ts b/apps/sim/tools/google_calendar/types.ts index 46b39f8a04..fc3cc83262 100644 --- a/apps/sim/tools/google_calendar/types.ts +++ b/apps/sim/tools/google_calendar/types.ts @@ -90,6 +90,14 @@ export interface GoogleCalendarInstancesParams extends BaseGoogleCalendarParams showDeleted?: boolean } +export interface GoogleCalendarFreeBusyParams { + accessToken: string + calendarIds: string // Comma-separated calendar IDs (e.g., "primary,other@example.com") + timeMin: string // RFC3339 timestamp (e.g., 2025-06-03T00:00:00Z) + timeMax: string // RFC3339 timestamp (e.g., 2025-06-04T00:00:00Z) + timeZone?: string // IANA time zone (e.g., "UTC", "America/New_York") +} + export interface GoogleCalendarListCalendarsParams { accessToken: string minAccessRole?: 'freeBusyReader' | 'reader' | 'writer' | 'owner' @@ -109,6 +117,7 @@ export type GoogleCalendarToolParams = | GoogleCalendarInviteParams | GoogleCalendarMoveParams | GoogleCalendarInstancesParams + | GoogleCalendarFreeBusyParams | GoogleCalendarListCalendarsParams interface EventMetadata { @@ -341,6 +350,36 @@ export interface GoogleCalendarInstancesResponse extends ToolResponse { } } +export interface GoogleCalendarFreeBusyResponse extends ToolResponse { + output: { + content: string + metadata: { + timeMin: string + timeMax: string + calendars: Record< + string, + { + busy: Array<{ start: string; end: string }> + errors?: Array<{ domain: string; reason: string }> + } + > + } + } +} + +export interface GoogleCalendarApiFreeBusyResponse { + kind: string + timeMin: string + timeMax: string + calendars: Record< + string, + { + busy: Array<{ start: string; end: string }> + errors?: Array<{ domain: string; reason: string }> + } + > +} + export interface GoogleCalendarListCalendarsResponse extends ToolResponse { output: { content: string @@ -373,4 +412,5 @@ export type GoogleCalendarResponse = | GoogleCalendarDeleteResponse | GoogleCalendarMoveResponse | GoogleCalendarInstancesResponse + | GoogleCalendarFreeBusyResponse | GoogleCalendarListCalendarsResponse diff --git a/apps/sim/tools/google_drive/index.ts b/apps/sim/tools/google_drive/index.ts index 58dbdb03e7..7c81c6c2fc 100644 --- a/apps/sim/tools/google_drive/index.ts +++ b/apps/sim/tools/google_drive/index.ts @@ -7,6 +7,8 @@ import { getContentTool } from '@/tools/google_drive/get_content' import { getFileTool } from '@/tools/google_drive/get_file' import { listTool } from '@/tools/google_drive/list' import { listPermissionsTool } from '@/tools/google_drive/list_permissions' +import { moveTool } from '@/tools/google_drive/move' +import { searchTool } from '@/tools/google_drive/search' import { shareTool } from '@/tools/google_drive/share' import { trashTool } from '@/tools/google_drive/trash' import { unshareTool } from '@/tools/google_drive/unshare' @@ -23,6 +25,8 @@ export const googleDriveGetContentTool = getContentTool export const googleDriveGetFileTool = getFileTool export const googleDriveListTool = listTool export const googleDriveListPermissionsTool = listPermissionsTool +export const googleDriveMoveTool = moveTool +export const googleDriveSearchTool = searchTool export const googleDriveShareTool = shareTool export const googleDriveTrashTool = trashTool export const googleDriveUnshareTool = unshareTool diff --git a/apps/sim/tools/google_drive/move.ts b/apps/sim/tools/google_drive/move.ts new file mode 100644 index 0000000000..4358b7d29b --- /dev/null +++ b/apps/sim/tools/google_drive/move.ts @@ -0,0 +1,145 @@ +import type { GoogleDriveFile, GoogleDriveToolParams } from '@/tools/google_drive/types' +import { ALL_FILE_FIELDS } from '@/tools/google_drive/utils' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +interface GoogleDriveMoveParams extends GoogleDriveToolParams { + fileId: string + destinationFolderId: string + removeFromCurrent?: boolean +} + +interface GoogleDriveMoveResponse extends ToolResponse { + output: { + file: GoogleDriveFile + } +} + +export const moveTool: ToolConfig = { + id: 'google_drive_move', + name: 'Move Google Drive File', + description: 'Move a file or folder to a different folder in Google Drive', + version: '1.0.0', + + oauth: { + required: true, + provider: 'google-drive', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token', + }, + fileId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the file or folder to move', + }, + destinationFolderId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the destination folder', + }, + removeFromCurrent: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'Whether to remove the file from its current parent folder (default: true). Set to false to add the file to the destination without removing it from the current location.', + }, + }, + + request: { + url: 'https://www.googleapis.com/drive/v3/files', + method: 'PATCH', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + }, + + directExecution: async (params) => { + const fileId = params.fileId?.trim() + const destinationFolderId = params.destinationFolderId?.trim() + const removeFromCurrent = params.removeFromCurrent !== false + + if (!fileId) { + throw new Error('fileId is required') + } + if (!destinationFolderId) { + throw new Error('destinationFolderId is required') + } + + const headers = { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + + // Build the PATCH URL with addParents + const url = new URL(`https://www.googleapis.com/drive/v3/files/${fileId}`) + url.searchParams.append('addParents', destinationFolderId) + url.searchParams.append('fields', ALL_FILE_FIELDS) + url.searchParams.append('supportsAllDrives', 'true') + + if (removeFromCurrent) { + // Fetch current parents so we can remove them + const metadataUrl = new URL(`https://www.googleapis.com/drive/v3/files/${fileId}`) + metadataUrl.searchParams.append('fields', 'parents') + metadataUrl.searchParams.append('supportsAllDrives', 'true') + + const metadataResponse = await fetch(metadataUrl.toString(), { headers }) + + if (!metadataResponse.ok) { + const errorData = await metadataResponse.json() + throw new Error(errorData.error?.message || 'Failed to retrieve file metadata') + } + + const metadata = await metadataResponse.json() + if (metadata.parents && metadata.parents.length > 0) { + url.searchParams.append('removeParents', metadata.parents.join(',')) + } + } + + const response = await fetch(url.toString(), { + method: 'PATCH', + headers, + body: JSON.stringify({}), + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error?.message || 'Failed to move Google Drive file') + } + + return { + success: true, + output: { + file: data, + }, + } + }, + + outputs: { + file: { + type: 'json', + description: 'The moved file metadata', + properties: { + id: { type: 'string', description: 'Google Drive file ID' }, + kind: { type: 'string', description: 'Resource type identifier' }, + name: { type: 'string', description: 'File name' }, + mimeType: { type: 'string', description: 'MIME type' }, + webViewLink: { type: 'string', description: 'URL to view in browser' }, + parents: { type: 'json', description: 'Parent folder IDs' }, + createdTime: { type: 'string', description: 'File creation time' }, + modifiedTime: { type: 'string', description: 'Last modification time' }, + owners: { type: 'json', description: 'List of file owners' }, + size: { type: 'string', description: 'File size in bytes' }, + }, + }, + }, +} diff --git a/apps/sim/tools/google_drive/search.ts b/apps/sim/tools/google_drive/search.ts new file mode 100644 index 0000000000..52eccb7ef6 --- /dev/null +++ b/apps/sim/tools/google_drive/search.ts @@ -0,0 +1,145 @@ +import type { GoogleDriveFile, GoogleDriveToolParams } from '@/tools/google_drive/types' +import { ALL_FILE_FIELDS } from '@/tools/google_drive/utils' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +interface GoogleDriveSearchParams extends GoogleDriveToolParams { + query: string + pageSize?: number + pageToken?: string +} + +interface GoogleDriveSearchResponse extends ToolResponse { + output: { + files: GoogleDriveFile[] + nextPageToken?: string + } +} + +export const searchTool: ToolConfig = { + id: 'google_drive_search', + name: 'Search Google Drive Files', + description: + 'Search for files in Google Drive using advanced query syntax (e.g., fullText contains, mimeType, modifiedTime, etc.)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'google-drive', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token', + }, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Google Drive query string using advanced search syntax (e.g., "fullText contains \'budget\'", "mimeType = \'application/pdf\'", "modifiedTime > \'2024-01-01\'")', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of files to return (default: 100)', + }, + pageToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Token for fetching the next page of results', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://www.googleapis.com/drive/v3/files') + url.searchParams.append('fields', `files(${ALL_FILE_FIELDS}),nextPageToken`) + url.searchParams.append('corpora', 'allDrives') + url.searchParams.append('supportsAllDrives', 'true') + url.searchParams.append('includeItemsFromAllDrives', 'true') + + // The query is passed directly as Google Drive query syntax + const conditions = ['trashed = false'] + if (params.query?.trim()) { + conditions.push(params.query.trim()) + } + url.searchParams.append('q', conditions.join(' and ')) + + if (params.pageSize) { + url.searchParams.append('pageSize', Number(params.pageSize).toString()) + } + if (params.pageToken) { + url.searchParams.append('pageToken', params.pageToken) + } + + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error?.message || 'Failed to search Google Drive files') + } + + return { + success: true, + output: { + files: data.files || [], + nextPageToken: data.nextPageToken, + }, + } + }, + + outputs: { + files: { + type: 'array', + description: 'Array of file metadata objects matching the search query', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Google Drive file ID' }, + kind: { type: 'string', description: 'Resource type identifier' }, + name: { type: 'string', description: 'File name' }, + mimeType: { type: 'string', description: 'MIME type' }, + description: { type: 'string', description: 'File description' }, + originalFilename: { type: 'string', description: 'Original uploaded filename' }, + fullFileExtension: { type: 'string', description: 'Full file extension' }, + fileExtension: { type: 'string', description: 'File extension' }, + owners: { type: 'json', description: 'List of file owners' }, + permissions: { type: 'json', description: 'File permissions' }, + shared: { type: 'boolean', description: 'Whether file is shared' }, + ownedByMe: { type: 'boolean', description: 'Whether owned by current user' }, + starred: { type: 'boolean', description: 'Whether file is starred' }, + trashed: { type: 'boolean', description: 'Whether file is in trash' }, + createdTime: { type: 'string', description: 'File creation time' }, + modifiedTime: { type: 'string', description: 'Last modification time' }, + lastModifyingUser: { type: 'json', description: 'User who last modified the file' }, + webViewLink: { type: 'string', description: 'URL to view in browser' }, + webContentLink: { type: 'string', description: 'Direct download URL' }, + iconLink: { type: 'string', description: 'URL to file icon' }, + thumbnailLink: { type: 'string', description: 'URL to thumbnail' }, + size: { type: 'string', description: 'File size in bytes' }, + parents: { type: 'json', description: 'Parent folder IDs' }, + driveId: { type: 'string', description: 'Shared drive ID' }, + capabilities: { type: 'json', description: 'User capabilities on file' }, + version: { type: 'string', description: 'Version number' }, + }, + }, + }, + nextPageToken: { + type: 'string', + description: 'Token for fetching the next page of results', + }, + }, +} diff --git a/apps/sim/tools/google_sheets/delete_rows.ts b/apps/sim/tools/google_sheets/delete_rows.ts new file mode 100644 index 0000000000..9e146d3ad3 --- /dev/null +++ b/apps/sim/tools/google_sheets/delete_rows.ts @@ -0,0 +1,156 @@ +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface GoogleSheetsV2DeleteRowsParams { + accessToken: string + spreadsheetId: string + sheetId: number + startIndex: number + endIndex: number +} + +export interface GoogleSheetsV2DeleteRowsResponse extends ToolResponse { + output: { + spreadsheetId: string + sheetId: number + deletedRowRange: string + metadata: { + spreadsheetId: string + spreadsheetUrl: string + } + } +} + +export const deleteRowsV2Tool: ToolConfig< + GoogleSheetsV2DeleteRowsParams, + GoogleSheetsV2DeleteRowsResponse +> = { + id: 'google_sheets_delete_rows_v2', + name: 'Delete Rows from Google Sheets V2', + description: 'Delete rows from a sheet in a Google Sheets spreadsheet', + version: '2.0.0', + + oauth: { + required: true, + provider: 'google-sheets', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the Google Sheets API', + }, + spreadsheetId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Google Sheets spreadsheet ID', + }, + sheetId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: + 'The numeric ID of the sheet/tab (not the sheet name). Use Get Spreadsheet to find sheet IDs.', + }, + startIndex: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The start row index (0-based, inclusive) of the rows to delete', + }, + endIndex: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The end row index (0-based, exclusive) of the rows to delete', + }, + }, + + request: { + url: (params) => { + const spreadsheetId = params.spreadsheetId?.trim() + if (!spreadsheetId) { + throw new Error('Spreadsheet ID is required') + } + + return `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}:batchUpdate` + }, + method: 'POST', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => { + if (params.sheetId === undefined || params.sheetId === null) { + throw new Error('Sheet ID is required') + } + if (params.startIndex === undefined || params.startIndex === null) { + throw new Error('Start index is required') + } + if (params.endIndex === undefined || params.endIndex === null) { + throw new Error('End index is required') + } + + return { + requests: [ + { + deleteDimension: { + range: { + sheetId: params.sheetId, + dimension: 'ROWS', + startIndex: params.startIndex, + endIndex: params.endIndex, + }, + }, + }, + ], + } + }, + }, + + transformResponse: async (response: Response, params?: GoogleSheetsV2DeleteRowsParams) => { + await response.json() + + const spreadsheetId = params?.spreadsheetId ?? '' + const startIndex = params?.startIndex ?? 0 + const endIndex = params?.endIndex ?? 0 + + return { + success: true, + output: { + spreadsheetId, + sheetId: params?.sheetId ?? 0, + deletedRowRange: `rows ${startIndex} to ${endIndex}`, + metadata: { + spreadsheetId, + spreadsheetUrl: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`, + }, + }, + } + }, + + outputs: { + spreadsheetId: { type: 'string', description: 'Google Sheets spreadsheet ID' }, + sheetId: { type: 'number', description: 'The numeric ID of the sheet' }, + deletedRowRange: { + type: 'string', + description: 'Description of the deleted row range', + }, + metadata: { + type: 'json', + description: 'Spreadsheet metadata including ID and URL', + properties: { + spreadsheetId: { type: 'string', description: 'Google Sheets spreadsheet ID' }, + spreadsheetUrl: { type: 'string', description: 'Spreadsheet URL' }, + }, + }, + }, +} diff --git a/apps/sim/tools/google_sheets/delete_sheet.ts b/apps/sim/tools/google_sheets/delete_sheet.ts new file mode 100644 index 0000000000..b7a7246680 --- /dev/null +++ b/apps/sim/tools/google_sheets/delete_sheet.ts @@ -0,0 +1,123 @@ +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface GoogleSheetsV2DeleteSheetParams { + accessToken: string + spreadsheetId: string + sheetId: number +} + +export interface GoogleSheetsV2DeleteSheetResponse extends ToolResponse { + output: { + spreadsheetId: string + deletedSheetId: number + metadata: { + spreadsheetId: string + spreadsheetUrl: string + } + } +} + +export const deleteSheetV2Tool: ToolConfig< + GoogleSheetsV2DeleteSheetParams, + GoogleSheetsV2DeleteSheetResponse +> = { + id: 'google_sheets_delete_sheet_v2', + name: 'Delete Sheet V2', + description: 'Delete a sheet/tab from a Google Sheets spreadsheet', + version: '2.0.0', + + oauth: { + required: true, + provider: 'google-sheets', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the Google Sheets API', + }, + spreadsheetId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Google Sheets spreadsheet ID', + }, + sheetId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: + 'The numeric ID of the sheet/tab to delete (not the sheet name). Use Get Spreadsheet to find sheet IDs.', + }, + }, + + request: { + url: (params) => { + const spreadsheetId = params.spreadsheetId?.trim() + if (!spreadsheetId) { + throw new Error('Spreadsheet ID is required') + } + + return `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}:batchUpdate` + }, + method: 'POST', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => { + if (params.sheetId === undefined || params.sheetId === null) { + throw new Error('Sheet ID is required') + } + + return { + requests: [ + { + deleteSheet: { + sheetId: params.sheetId, + }, + }, + ], + } + }, + }, + + transformResponse: async (response: Response, params?: GoogleSheetsV2DeleteSheetParams) => { + await response.json() + + const spreadsheetId = params?.spreadsheetId ?? '' + + return { + success: true, + output: { + spreadsheetId, + deletedSheetId: params?.sheetId ?? 0, + metadata: { + spreadsheetId, + spreadsheetUrl: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`, + }, + }, + } + }, + + outputs: { + spreadsheetId: { type: 'string', description: 'Google Sheets spreadsheet ID' }, + deletedSheetId: { type: 'number', description: 'The numeric ID of the deleted sheet' }, + metadata: { + type: 'json', + description: 'Spreadsheet metadata including ID and URL', + properties: { + spreadsheetId: { type: 'string', description: 'Google Sheets spreadsheet ID' }, + spreadsheetUrl: { type: 'string', description: 'Spreadsheet URL' }, + }, + }, + }, +} diff --git a/apps/sim/tools/google_sheets/delete_spreadsheet.ts b/apps/sim/tools/google_sheets/delete_spreadsheet.ts new file mode 100644 index 0000000000..70b7138c90 --- /dev/null +++ b/apps/sim/tools/google_sheets/delete_spreadsheet.ts @@ -0,0 +1,86 @@ +import type { ToolConfig, ToolResponse } from '@/tools/types' + +export interface GoogleSheetsV2DeleteSpreadsheetParams { + accessToken: string + spreadsheetId: string +} + +export interface GoogleSheetsV2DeleteSpreadsheetResponse extends ToolResponse { + output: { + spreadsheetId: string + deleted: boolean + } +} + +export const deleteSpreadsheetV2Tool: ToolConfig< + GoogleSheetsV2DeleteSpreadsheetParams, + GoogleSheetsV2DeleteSpreadsheetResponse +> = { + id: 'google_sheets_delete_spreadsheet_v2', + name: 'Delete Spreadsheet V2', + description: 'Permanently delete a Google Sheets spreadsheet using the Google Drive API', + version: '2.0.0', + + oauth: { + required: true, + provider: 'google-sheets', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the Google Sheets API', + }, + spreadsheetId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the Google Sheets spreadsheet to delete', + }, + }, + + request: { + url: (params) => { + const spreadsheetId = params.spreadsheetId?.trim() + if (!spreadsheetId) { + throw new Error('Spreadsheet ID is required') + } + + return `https://www.googleapis.com/drive/v3/files/${spreadsheetId}` + }, + method: 'DELETE', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + } + }, + }, + + transformResponse: async (response: Response, params?: GoogleSheetsV2DeleteSpreadsheetParams) => { + const spreadsheetId = params?.spreadsheetId ?? '' + + if (response.status === 204 || response.ok) { + return { + success: true, + output: { + spreadsheetId, + deleted: true, + }, + } + } + + const data = await response.json() + throw new Error(data.error?.message ?? 'Failed to delete spreadsheet') + }, + + outputs: { + spreadsheetId: { type: 'string', description: 'The ID of the deleted spreadsheet' }, + deleted: { type: 'boolean', description: 'Whether the spreadsheet was successfully deleted' }, + }, +} diff --git a/apps/sim/tools/google_sheets/index.ts b/apps/sim/tools/google_sheets/index.ts index 750e0a3b26..47422712ff 100644 --- a/apps/sim/tools/google_sheets/index.ts +++ b/apps/sim/tools/google_sheets/index.ts @@ -5,6 +5,9 @@ import { batchUpdateV2Tool } from '@/tools/google_sheets/batch_update' import { clearV2Tool } from '@/tools/google_sheets/clear' import { copySheetV2Tool } from '@/tools/google_sheets/copy_sheet' import { createSpreadsheetV2Tool } from '@/tools/google_sheets/create_spreadsheet' +import { deleteRowsV2Tool } from '@/tools/google_sheets/delete_rows' +import { deleteSheetV2Tool } from '@/tools/google_sheets/delete_sheet' +import { deleteSpreadsheetV2Tool } from '@/tools/google_sheets/delete_spreadsheet' import { getSpreadsheetV2Tool } from '@/tools/google_sheets/get_spreadsheet' import { readTool, readV2Tool } from '@/tools/google_sheets/read' import { updateTool, updateV2Tool } from '@/tools/google_sheets/update' @@ -28,3 +31,6 @@ export const googleSheetsBatchGetV2Tool = batchGetV2Tool export const googleSheetsBatchUpdateV2Tool = batchUpdateV2Tool export const googleSheetsBatchClearV2Tool = batchClearV2Tool export const googleSheetsCopySheetV2Tool = copySheetV2Tool +export const googleSheetsDeleteRowsV2Tool = deleteRowsV2Tool +export const googleSheetsDeleteSheetV2Tool = deleteSheetV2Tool +export const googleSheetsDeleteSpreadsheetV2Tool = deleteSpreadsheetV2Tool diff --git a/apps/sim/tools/google_sheets/types.ts b/apps/sim/tools/google_sheets/types.ts index 2c0c7aa5a4..66a080d9df 100644 --- a/apps/sim/tools/google_sheets/types.ts +++ b/apps/sim/tools/google_sheets/types.ts @@ -1,3 +1,6 @@ +import type { GoogleSheetsV2DeleteRowsResponse } from '@/tools/google_sheets/delete_rows' +import type { GoogleSheetsV2DeleteSheetResponse } from '@/tools/google_sheets/delete_sheet' +import type { GoogleSheetsV2DeleteSpreadsheetResponse } from '@/tools/google_sheets/delete_spreadsheet' import type { ToolResponse } from '@/tools/types' export interface GoogleSheetsRange { @@ -146,6 +149,9 @@ export type GoogleSheetsV2Response = | GoogleSheetsV2BatchUpdateResponse | GoogleSheetsV2BatchClearResponse | GoogleSheetsV2CopySheetResponse + | GoogleSheetsV2DeleteRowsResponse + | GoogleSheetsV2DeleteSheetResponse + | GoogleSheetsV2DeleteSpreadsheetResponse // V2 Clear Types export interface GoogleSheetsV2ClearParams { diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index ab49e04850..2d1b0e24be 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -578,10 +578,18 @@ import { gmailAddLabelV2Tool, gmailArchiveTool, gmailArchiveV2Tool, + gmailCreateLabelV2Tool, + gmailDeleteDraftV2Tool, + gmailDeleteLabelV2Tool, gmailDeleteTool, gmailDeleteV2Tool, gmailDraftTool, gmailDraftV2Tool, + gmailGetDraftV2Tool, + gmailGetThreadV2Tool, + gmailListDraftsV2Tool, + gmailListLabelsV2Tool, + gmailListThreadsV2Tool, gmailMarkReadTool, gmailMarkReadV2Tool, gmailMarkUnreadTool, @@ -596,8 +604,10 @@ import { gmailSearchV2Tool, gmailSendTool, gmailSendV2Tool, + gmailTrashThreadV2Tool, gmailUnarchiveTool, gmailUnarchiveV2Tool, + gmailUntrashThreadV2Tool, } from '@/tools/gmail' import { gongAggregateActivityTool, @@ -626,6 +636,8 @@ import { googleCalendarCreateV2Tool, googleCalendarDeleteTool, googleCalendarDeleteV2Tool, + googleCalendarFreeBusyTool, + googleCalendarFreeBusyV2Tool, googleCalendarGetTool, googleCalendarGetV2Tool, googleCalendarInstancesTool, @@ -654,6 +666,8 @@ import { googleDriveGetFileTool, googleDriveListPermissionsTool, googleDriveListTool, + googleDriveMoveTool, + googleDriveSearchTool, googleDriveShareTool, googleDriveTrashTool, googleDriveUnshareTool, @@ -714,6 +728,9 @@ import { googleSheetsClearV2Tool, googleSheetsCopySheetV2Tool, googleSheetsCreateSpreadsheetV2Tool, + googleSheetsDeleteRowsV2Tool, + googleSheetsDeleteSheetV2Tool, + googleSheetsDeleteSpreadsheetV2Tool, googleSheetsGetSpreadsheetV2Tool, googleSheetsReadTool, googleSheetsReadV2Tool, @@ -2472,6 +2489,16 @@ export const tools: Record = { gmail_add_label_v2: gmailAddLabelV2Tool, gmail_remove_label: gmailRemoveLabelTool, gmail_remove_label_v2: gmailRemoveLabelV2Tool, + gmail_create_label_v2: gmailCreateLabelV2Tool, + gmail_delete_draft_v2: gmailDeleteDraftV2Tool, + gmail_delete_label_v2: gmailDeleteLabelV2Tool, + gmail_get_draft_v2: gmailGetDraftV2Tool, + gmail_get_thread_v2: gmailGetThreadV2Tool, + gmail_list_drafts_v2: gmailListDraftsV2Tool, + gmail_list_labels_v2: gmailListLabelsV2Tool, + gmail_list_threads_v2: gmailListThreadsV2Tool, + gmail_trash_thread_v2: gmailTrashThreadV2Tool, + gmail_untrash_thread_v2: gmailUntrashThreadV2Tool, whatsapp_send_message: whatsappSendMessageTool, x_write: xWriteTool, x_read: xReadTool, @@ -2868,6 +2895,8 @@ export const tools: Record = { google_drive_get_file: googleDriveGetFileTool, google_drive_list: googleDriveListTool, google_drive_list_permissions: googleDriveListPermissionsTool, + google_drive_move: googleDriveMoveTool, + google_drive_search: googleDriveSearchTool, google_drive_share: googleDriveShareTool, google_drive_trash: googleDriveTrashTool, google_drive_unshare: googleDriveUnshareTool, @@ -2909,6 +2938,9 @@ export const tools: Record = { google_sheets_batch_update_v2: googleSheetsBatchUpdateV2Tool, google_sheets_batch_clear_v2: googleSheetsBatchClearV2Tool, google_sheets_copy_sheet_v2: googleSheetsCopySheetV2Tool, + google_sheets_delete_rows_v2: googleSheetsDeleteRowsV2Tool, + google_sheets_delete_sheet_v2: googleSheetsDeleteSheetV2Tool, + google_sheets_delete_spreadsheet_v2: googleSheetsDeleteSpreadsheetV2Tool, google_slides_read: googleSlidesReadTool, google_slides_write: googleSlidesWriteTool, google_slides_create: googleSlidesCreateTool, @@ -3508,6 +3540,8 @@ export const tools: Record = { google_calendar_quick_add_v2: googleCalendarQuickAddV2Tool, google_calendar_update: googleCalendarUpdateTool, google_calendar_update_v2: googleCalendarUpdateV2Tool, + google_calendar_freebusy: googleCalendarFreeBusyTool, + google_calendar_freebusy_v2: googleCalendarFreeBusyV2Tool, google_forms_get_responses: googleFormsGetResponsesTool, google_forms_get_form: googleFormsGetFormTool, google_forms_create_form: googleFormsCreateFormTool, From d0c2af464569f8929062021cee32c0d3d8236a9a Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 25 Feb 2026 13:45:19 -0800 Subject: [PATCH 07/12] feat(confluence): return page content in get page version tool (#3344) * feat(confluence): return page content in get page version tool * lint --- .../docs/content/docs/en/tools/confluence.mdx | 5 ++ .../tools/confluence/page-versions/route.ts | 83 ++++++++++++++----- apps/sim/tools/confluence/get_page_version.ts | 36 +++++++- apps/sim/tools/confluence/utils.ts | 14 +++- 4 files changed, 111 insertions(+), 27 deletions(-) diff --git a/apps/docs/content/docs/en/tools/confluence.mdx b/apps/docs/content/docs/en/tools/confluence.mdx index 7ee0f0e73e..58301d2891 100644 --- a/apps/docs/content/docs/en/tools/confluence.mdx +++ b/apps/docs/content/docs/en/tools/confluence.mdx @@ -326,6 +326,8 @@ Get details about a specific version of a Confluence page. | --------- | ---- | ----------- | | `ts` | string | ISO 8601 timestamp of the operation | | `pageId` | string | ID of the page | +| `title` | string | Page title at this version | +| `content` | string | Page content with HTML tags stripped at this version | | `version` | object | Detailed version information | | ↳ `number` | number | Version number | | ↳ `message` | string | Version message | @@ -336,6 +338,9 @@ Get details about a specific version of a Confluence page. | ↳ `collaborators` | array | List of collaborator account IDs for this version | | ↳ `prevVersion` | number | Previous version number | | ↳ `nextVersion` | number | Next version number | +| `body` | object | Raw page body content in storage format at this version | +| ↳ `value` | string | The content value in the specified format | +| ↳ `representation` | string | Content representation type | ### `confluence_list_page_properties` diff --git a/apps/sim/app/api/tools/confluence/page-versions/route.ts b/apps/sim/app/api/tools/confluence/page-versions/route.ts index 9d7c162060..cdd77f4e76 100644 --- a/apps/sim/app/api/tools/confluence/page-versions/route.ts +++ b/apps/sim/app/api/tools/confluence/page-versions/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' -import { getConfluenceCloudId } from '@/tools/confluence/utils' +import { cleanHtmlContent, getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluencePageVersionsAPI') @@ -55,42 +55,79 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) } - // If versionNumber is provided, get specific version + // If versionNumber is provided, get specific version with page content if (versionNumber !== undefined && versionNumber !== null) { - const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/versions/${versionNumber}` + const versionUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/versions/${versionNumber}` + const pageUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}?version=${versionNumber}&body-format=storage` logger.info(`Fetching version ${versionNumber} for page ${pageId}`) - const response = await fetch(url, { - method: 'GET', - headers: { - Accept: 'application/json', - Authorization: `Bearer ${accessToken}`, - }, - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => null) + const [versionResponse, pageResponse] = await Promise.all([ + fetch(versionUrl, { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }), + fetch(pageUrl, { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }), + ]) + + if (!versionResponse.ok) { + const errorData = await versionResponse.json().catch(() => null) logger.error('Confluence API error response:', { - status: response.status, - statusText: response.statusText, + status: versionResponse.status, + statusText: versionResponse.statusText, error: JSON.stringify(errorData, null, 2), }) - const errorMessage = errorData?.message || `Failed to get page version (${response.status})` - return NextResponse.json({ error: errorMessage }, { status: response.status }) + const errorMessage = + errorData?.message || `Failed to get page version (${versionResponse.status})` + return NextResponse.json({ error: errorMessage }, { status: versionResponse.status }) } - const data = await response.json() + const versionData = await versionResponse.json() + + let title: string | null = null + let content: string | null = null + let body: Record | null = null + + if (pageResponse.ok) { + const pageData = await pageResponse.json() + title = pageData.title ?? null + body = pageData.body ?? null + + const rawContent = + pageData.body?.storage?.value || + pageData.body?.view?.value || + pageData.body?.atlas_doc_format?.value || + '' + if (rawContent) { + content = cleanHtmlContent(rawContent) + } + } else { + logger.warn( + `Could not fetch page content for version ${versionNumber}: ${pageResponse.status}` + ) + } return NextResponse.json({ version: { - number: data.number, - message: data.message ?? null, - minorEdit: data.minorEdit ?? false, - authorId: data.authorId ?? null, - createdAt: data.createdAt ?? null, + number: versionData.number, + message: versionData.message ?? null, + minorEdit: versionData.minorEdit ?? false, + authorId: versionData.authorId ?? null, + createdAt: versionData.createdAt ?? null, }, pageId, + title, + content, + body, }) } // List all versions diff --git a/apps/sim/tools/confluence/get_page_version.ts b/apps/sim/tools/confluence/get_page_version.ts index c162e25465..dc496b38a2 100644 --- a/apps/sim/tools/confluence/get_page_version.ts +++ b/apps/sim/tools/confluence/get_page_version.ts @@ -1,4 +1,8 @@ -import { DETAILED_VERSION_OUTPUT_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types' +import { + BODY_FORMAT_PROPERTIES, + DETAILED_VERSION_OUTPUT_PROPERTIES, + TIMESTAMP_OUTPUT, +} from '@/tools/confluence/types' import type { ToolConfig } from '@/tools/types' export interface ConfluenceGetPageVersionParams { @@ -14,6 +18,8 @@ export interface ConfluenceGetPageVersionResponse { output: { ts: string pageId: string + title: string | null + content: string | null version: { number: number message: string | null @@ -25,6 +31,12 @@ export interface ConfluenceGetPageVersionResponse { prevVersion: number | null nextVersion: number | null } + body: { + storage?: { + value: string + representation: string + } + } | null } } @@ -100,6 +112,8 @@ export const confluenceGetPageVersionTool: ToolConfig< output: { ts: new Date().toISOString(), pageId: data.pageId ?? '', + title: data.title ?? null, + content: data.content ?? null, version: data.version ?? { number: 0, message: null, @@ -107,6 +121,7 @@ export const confluenceGetPageVersionTool: ToolConfig< authorId: null, createdAt: null, }, + body: data.body ?? null, }, } }, @@ -114,10 +129,29 @@ export const confluenceGetPageVersionTool: ToolConfig< outputs: { ts: TIMESTAMP_OUTPUT, pageId: { type: 'string', description: 'ID of the page' }, + title: { type: 'string', description: 'Page title at this version', optional: true }, + content: { + type: 'string', + description: 'Page content with HTML tags stripped at this version', + optional: true, + }, version: { type: 'object', description: 'Detailed version information', properties: DETAILED_VERSION_OUTPUT_PROPERTIES, }, + body: { + type: 'object', + description: 'Raw page body content in storage format at this version', + properties: { + storage: { + type: 'object', + description: 'Body in storage format (Confluence markup)', + properties: BODY_FORMAT_PROPERTIES, + optional: true, + }, + }, + optional: true, + }, }, } diff --git a/apps/sim/tools/confluence/utils.ts b/apps/sim/tools/confluence/utils.ts index 2f57929654..d7e55a2c56 100644 --- a/apps/sim/tools/confluence/utils.ts +++ b/apps/sim/tools/confluence/utils.ts @@ -56,13 +56,21 @@ function stripHtmlTags(html: string): string { return text.trim() } +/** + * Strips HTML tags and decodes HTML entities from raw Confluence content. + */ +export function cleanHtmlContent(rawContent: string): string { + let content = stripHtmlTags(rawContent) + content = decodeHtmlEntities(content) + content = content.replace(/\s+/g, ' ').trim() + return content +} + export function transformPageData(data: any) { const rawContent = data.body?.storage?.value || data.body?.view?.value || data.body?.atlas_doc_format?.value || '' - let cleanContent = stripHtmlTags(rawContent) - cleanContent = decodeHtmlEntities(cleanContent) - cleanContent = cleanContent.replace(/\s+/g, ' ').trim() + const cleanContent = cleanHtmlContent(rawContent) return { success: true, From 919c691b4c361df4f1fe47c1b880a37b0d5e1fda Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 25 Feb 2026 13:46:37 -0800 Subject: [PATCH 08/12] feat(api): audit log read endpoints for admin and enterprise (#3343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): audit log read endpoints for admin and enterprise * fix(api): address PR review — boolean coercion, cursor validation, detail scope * ran lint --- .../app/api/v1/admin/audit-logs/[id]/route.ts | 44 ++++ apps/sim/app/api/v1/admin/audit-logs/route.ts | 96 +++++++++ apps/sim/app/api/v1/admin/types.ts | 43 ++++ apps/sim/app/api/v1/audit-logs/[id]/route.ts | 78 +++++++ apps/sim/app/api/v1/audit-logs/auth.ts | 103 ++++++++++ apps/sim/app/api/v1/audit-logs/format.ts | 43 ++++ apps/sim/app/api/v1/audit-logs/route.ts | 191 ++++++++++++++++++ apps/sim/app/api/v1/middleware.ts | 2 +- 8 files changed, 599 insertions(+), 1 deletion(-) create mode 100644 apps/sim/app/api/v1/admin/audit-logs/[id]/route.ts create mode 100644 apps/sim/app/api/v1/admin/audit-logs/route.ts create mode 100644 apps/sim/app/api/v1/audit-logs/[id]/route.ts create mode 100644 apps/sim/app/api/v1/audit-logs/auth.ts create mode 100644 apps/sim/app/api/v1/audit-logs/format.ts create mode 100644 apps/sim/app/api/v1/audit-logs/route.ts diff --git a/apps/sim/app/api/v1/admin/audit-logs/[id]/route.ts b/apps/sim/app/api/v1/admin/audit-logs/[id]/route.ts new file mode 100644 index 0000000000..848fbc8b31 --- /dev/null +++ b/apps/sim/app/api/v1/admin/audit-logs/[id]/route.ts @@ -0,0 +1,44 @@ +/** + * GET /api/v1/admin/audit-logs/[id] + * + * Get a single audit log entry by ID. + * + * Response: AdminSingleResponse + */ + +import { db } from '@sim/db' +import { auditLog } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' +import { + internalErrorResponse, + notFoundResponse, + singleResponse, +} from '@/app/api/v1/admin/responses' +import { toAdminAuditLog } from '@/app/api/v1/admin/types' + +const logger = createLogger('AdminAuditLogDetailAPI') + +interface RouteParams { + id: string +} + +export const GET = withAdminAuthParams(async (request, context) => { + const { id } = await context.params + + try { + const [log] = await db.select().from(auditLog).where(eq(auditLog.id, id)).limit(1) + + if (!log) { + return notFoundResponse('AuditLog') + } + + logger.info(`Admin API: Retrieved audit log ${id}`) + + return singleResponse(toAdminAuditLog(log)) + } catch (error) { + logger.error('Admin API: Failed to get audit log', { error, id }) + return internalErrorResponse('Failed to get audit log') + } +}) diff --git a/apps/sim/app/api/v1/admin/audit-logs/route.ts b/apps/sim/app/api/v1/admin/audit-logs/route.ts new file mode 100644 index 0000000000..895ac1ff3e --- /dev/null +++ b/apps/sim/app/api/v1/admin/audit-logs/route.ts @@ -0,0 +1,96 @@ +/** + * GET /api/v1/admin/audit-logs + * + * List all audit logs with pagination and filtering. + * + * Query Parameters: + * - limit: number (default: 50, max: 250) + * - offset: number (default: 0) + * - action: string (optional) - Filter by action (e.g., "workflow.created") + * - resourceType: string (optional) - Filter by resource type (e.g., "workflow") + * - resourceId: string (optional) - Filter by resource ID + * - workspaceId: string (optional) - Filter by workspace ID + * - actorId: string (optional) - Filter by actor user ID + * - actorEmail: string (optional) - Filter by actor email + * - startDate: string (optional) - ISO 8601 date, filter createdAt >= startDate + * - endDate: string (optional) - ISO 8601 date, filter createdAt <= endDate + * + * Response: AdminListResponse + */ + +import { db } from '@sim/db' +import { auditLog } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, count, desc, eq, gte, lte, type SQL } from 'drizzle-orm' +import { withAdminAuth } from '@/app/api/v1/admin/middleware' +import { + badRequestResponse, + internalErrorResponse, + listResponse, +} from '@/app/api/v1/admin/responses' +import { + type AdminAuditLog, + createPaginationMeta, + parsePaginationParams, + toAdminAuditLog, +} from '@/app/api/v1/admin/types' + +const logger = createLogger('AdminAuditLogsAPI') + +export const GET = withAdminAuth(async (request) => { + const url = new URL(request.url) + const { limit, offset } = parsePaginationParams(url) + + const actionFilter = url.searchParams.get('action') + const resourceTypeFilter = url.searchParams.get('resourceType') + const resourceIdFilter = url.searchParams.get('resourceId') + const workspaceIdFilter = url.searchParams.get('workspaceId') + const actorIdFilter = url.searchParams.get('actorId') + const actorEmailFilter = url.searchParams.get('actorEmail') + const startDateFilter = url.searchParams.get('startDate') + const endDateFilter = url.searchParams.get('endDate') + + if (startDateFilter && Number.isNaN(Date.parse(startDateFilter))) { + return badRequestResponse('Invalid startDate format. Use ISO 8601.') + } + if (endDateFilter && Number.isNaN(Date.parse(endDateFilter))) { + return badRequestResponse('Invalid endDate format. Use ISO 8601.') + } + + try { + const conditions: SQL[] = [] + + if (actionFilter) conditions.push(eq(auditLog.action, actionFilter)) + if (resourceTypeFilter) conditions.push(eq(auditLog.resourceType, resourceTypeFilter)) + if (resourceIdFilter) conditions.push(eq(auditLog.resourceId, resourceIdFilter)) + if (workspaceIdFilter) conditions.push(eq(auditLog.workspaceId, workspaceIdFilter)) + if (actorIdFilter) conditions.push(eq(auditLog.actorId, actorIdFilter)) + if (actorEmailFilter) conditions.push(eq(auditLog.actorEmail, actorEmailFilter)) + if (startDateFilter) conditions.push(gte(auditLog.createdAt, new Date(startDateFilter))) + if (endDateFilter) conditions.push(lte(auditLog.createdAt, new Date(endDateFilter))) + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined + + const [countResult, logs] = await Promise.all([ + db.select({ total: count() }).from(auditLog).where(whereClause), + db + .select() + .from(auditLog) + .where(whereClause) + .orderBy(desc(auditLog.createdAt)) + .limit(limit) + .offset(offset), + ]) + + const total = countResult[0].total + const data: AdminAuditLog[] = logs.map(toAdminAuditLog) + const pagination = createPaginationMeta(total, limit, offset) + + logger.info(`Admin API: Listed ${data.length} audit logs (total: ${total})`) + + return listResponse(data, pagination) + } catch (error) { + logger.error('Admin API: Failed to list audit logs', { error }) + return internalErrorResponse('Failed to list audit logs') + } +}) diff --git a/apps/sim/app/api/v1/admin/types.ts b/apps/sim/app/api/v1/admin/types.ts index d7ec4f5c3c..3cfd515b25 100644 --- a/apps/sim/app/api/v1/admin/types.ts +++ b/apps/sim/app/api/v1/admin/types.ts @@ -6,6 +6,7 @@ */ import type { + auditLog, member, organization, referralCampaigns, @@ -694,3 +695,45 @@ export function toAdminReferralCampaign( updatedAt: dbCampaign.updatedAt.toISOString(), } } + +// ============================================================================= +// Audit Log Types +// ============================================================================= + +export type DbAuditLog = InferSelectModel + +export interface AdminAuditLog { + id: string + workspaceId: string | null + actorId: string | null + actorName: string | null + actorEmail: string | null + action: string + resourceType: string + resourceId: string | null + resourceName: string | null + description: string | null + metadata: unknown + ipAddress: string | null + userAgent: string | null + createdAt: string +} + +export function toAdminAuditLog(dbLog: DbAuditLog): AdminAuditLog { + return { + id: dbLog.id, + workspaceId: dbLog.workspaceId, + actorId: dbLog.actorId, + actorName: dbLog.actorName, + actorEmail: dbLog.actorEmail, + action: dbLog.action, + resourceType: dbLog.resourceType, + resourceId: dbLog.resourceId, + resourceName: dbLog.resourceName, + description: dbLog.description, + metadata: dbLog.metadata, + ipAddress: dbLog.ipAddress, + userAgent: dbLog.userAgent, + createdAt: dbLog.createdAt.toISOString(), + } +} diff --git a/apps/sim/app/api/v1/audit-logs/[id]/route.ts b/apps/sim/app/api/v1/audit-logs/[id]/route.ts new file mode 100644 index 0000000000..3cf6351d2b --- /dev/null +++ b/apps/sim/app/api/v1/audit-logs/[id]/route.ts @@ -0,0 +1,78 @@ +/** + * GET /api/v1/audit-logs/[id] + * + * Get a single audit log entry by ID, scoped to the authenticated user's organization. + * Requires enterprise subscription and org admin/owner role. + * + * Scope includes logs from current org members AND logs within org workspaces + * (including those from departed members or system actions with null actorId). + * + * Response: { data: AuditLogEntry, limits: UserLimits } + */ + +import { db } from '@sim/db' +import { auditLog, workspace } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, inArray, or } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' +import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' +import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' + +const logger = createLogger('V1AuditLogDetailAPI') + +export const revalidate = 0 + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'audit-logs') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } + + const userId = rateLimit.userId! + const { id } = await params + + const authResult = await validateEnterpriseAuditAccess(userId) + if (!authResult.success) { + return authResult.response + } + + const { orgMemberIds } = authResult.context + + const orgWorkspaceIds = db + .select({ id: workspace.id }) + .from(workspace) + .where(inArray(workspace.ownerId, orgMemberIds)) + + const [log] = await db + .select() + .from(auditLog) + .where( + and( + eq(auditLog.id, id), + or( + inArray(auditLog.actorId, orgMemberIds), + inArray(auditLog.workspaceId, orgWorkspaceIds) + ) + ) + ) + .limit(1) + + if (!log) { + return NextResponse.json({ error: 'Audit log not found' }, { status: 404 }) + } + + const limits = await getUserLimits(userId) + const response = createApiResponse({ data: formatAuditLogEntry(log) }, limits, rateLimit) + + return NextResponse.json(response.body, { headers: response.headers }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error(`[${requestId}] Audit log detail fetch error`, { error: message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/v1/audit-logs/auth.ts b/apps/sim/app/api/v1/audit-logs/auth.ts new file mode 100644 index 0000000000..085884488e --- /dev/null +++ b/apps/sim/app/api/v1/audit-logs/auth.ts @@ -0,0 +1,103 @@ +/** + * Enterprise audit log authorization. + * + * Validates that the authenticated user is an admin/owner of an enterprise organization + * and returns the organization context needed for scoped queries. + */ + +import { db } from '@sim/db' +import { member, subscription } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' + +const logger = createLogger('V1AuditLogsAuth') + +export interface EnterpriseAuditContext { + organizationId: string + orgMemberIds: string[] +} + +type AuthResult = + | { success: true; context: EnterpriseAuditContext } + | { success: false; response: NextResponse } + +/** + * Validates enterprise audit log access for the given user. + * + * Checks: + * 1. User belongs to an organization + * 2. User has admin or owner role + * 3. Organization has an active enterprise subscription + * + * Returns the organization ID and all member user IDs on success, + * or an error response on failure. + */ +export async function validateEnterpriseAuditAccess(userId: string): Promise { + const [membership] = await db + .select({ organizationId: member.organizationId, role: member.role }) + .from(member) + .where(eq(member.userId, userId)) + .limit(1) + + if (!membership) { + return { + success: false, + response: NextResponse.json({ error: 'Not a member of any organization' }, { status: 403 }), + } + } + + if (membership.role !== 'admin' && membership.role !== 'owner') { + return { + success: false, + response: NextResponse.json( + { error: 'Organization admin or owner role required' }, + { status: 403 } + ), + } + } + + const [orgSub, orgMembers] = await Promise.all([ + db + .select({ id: subscription.id }) + .from(subscription) + .where( + and( + eq(subscription.referenceId, membership.organizationId), + eq(subscription.plan, 'enterprise'), + eq(subscription.status, 'active') + ) + ) + .limit(1), + db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, membership.organizationId)), + ]) + + if (orgSub.length === 0) { + return { + success: false, + response: NextResponse.json( + { error: 'Active enterprise subscription required' }, + { status: 403 } + ), + } + } + + const orgMemberIds = orgMembers.map((m) => m.userId) + + logger.info('Enterprise audit access validated', { + userId, + organizationId: membership.organizationId, + memberCount: orgMemberIds.length, + }) + + return { + success: true, + context: { + organizationId: membership.organizationId, + orgMemberIds, + }, + } +} diff --git a/apps/sim/app/api/v1/audit-logs/format.ts b/apps/sim/app/api/v1/audit-logs/format.ts new file mode 100644 index 0000000000..5591f4f6f8 --- /dev/null +++ b/apps/sim/app/api/v1/audit-logs/format.ts @@ -0,0 +1,43 @@ +/** + * Enterprise audit log response formatting. + * + * Defines the shape returned by the enterprise audit log API. + * Excludes `ipAddress` and `userAgent` for privacy. + */ + +import type { auditLog } from '@sim/db/schema' +import type { InferSelectModel } from 'drizzle-orm' + +type DbAuditLog = InferSelectModel + +export interface EnterpriseAuditLogEntry { + id: string + workspaceId: string | null + actorId: string | null + actorName: string | null + actorEmail: string | null + action: string + resourceType: string + resourceId: string | null + resourceName: string | null + description: string | null + metadata: unknown + createdAt: string +} + +export function formatAuditLogEntry(log: DbAuditLog): EnterpriseAuditLogEntry { + return { + id: log.id, + workspaceId: log.workspaceId, + actorId: log.actorId, + actorName: log.actorName, + actorEmail: log.actorEmail, + action: log.action, + resourceType: log.resourceType, + resourceId: log.resourceId, + resourceName: log.resourceName, + description: log.description, + metadata: log.metadata, + createdAt: log.createdAt.toISOString(), + } +} diff --git a/apps/sim/app/api/v1/audit-logs/route.ts b/apps/sim/app/api/v1/audit-logs/route.ts new file mode 100644 index 0000000000..825cf37620 --- /dev/null +++ b/apps/sim/app/api/v1/audit-logs/route.ts @@ -0,0 +1,191 @@ +/** + * GET /api/v1/audit-logs + * + * List audit logs scoped to the authenticated user's organization. + * Requires enterprise subscription and org admin/owner role. + * + * Query Parameters: + * - action: string (optional) - Filter by action (e.g., "workflow.created") + * - resourceType: string (optional) - Filter by resource type (e.g., "workflow") + * - resourceId: string (optional) - Filter by resource ID + * - workspaceId: string (optional) - Filter by workspace ID + * - actorId: string (optional) - Filter by actor user ID (must be an org member) + * - startDate: string (optional) - ISO 8601 date, filter createdAt >= startDate + * - endDate: string (optional) - ISO 8601 date, filter createdAt <= endDate + * - includeDeparted: boolean (optional, default: false) - Include logs from departed members + * - limit: number (optional, default: 50, max: 100) + * - cursor: string (optional) - Opaque cursor for pagination + * + * Response: { data: AuditLogEntry[], nextCursor?: string, limits: UserLimits } + */ + +import { db } from '@sim/db' +import { auditLog, workspace } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, desc, eq, gte, inArray, lt, lte, or, type SQL } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' +import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' +import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' + +const logger = createLogger('V1AuditLogsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const isoDateString = z.string().refine((val) => !Number.isNaN(Date.parse(val)), { + message: 'Invalid date format. Use ISO 8601.', +}) + +const QueryParamsSchema = z.object({ + action: z.string().optional(), + resourceType: z.string().optional(), + resourceId: z.string().optional(), + workspaceId: z.string().optional(), + actorId: z.string().optional(), + startDate: isoDateString.optional(), + endDate: isoDateString.optional(), + includeDeparted: z + .enum(['true', 'false']) + .transform((val) => val === 'true') + .optional() + .default('false'), + limit: z.coerce.number().min(1).max(100).optional().default(50), + cursor: z.string().optional(), +}) + +interface CursorData { + createdAt: string + id: string +} + +function encodeCursor(data: CursorData): string { + return Buffer.from(JSON.stringify(data)).toString('base64') +} + +function decodeCursor(cursor: string): CursorData | null { + try { + return JSON.parse(Buffer.from(cursor, 'base64').toString()) + } catch { + return null + } +} + +export async function GET(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'audit-logs') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } + + const userId = rateLimit.userId! + + const authResult = await validateEnterpriseAuditAccess(userId) + if (!authResult.success) { + return authResult.response + } + + const { orgMemberIds } = authResult.context + + const { searchParams } = new URL(request.url) + const rawParams = Object.fromEntries(searchParams.entries()) + const validationResult = QueryParamsSchema.safeParse(rawParams) + + if (!validationResult.success) { + return NextResponse.json( + { error: 'Invalid parameters', details: validationResult.error.errors }, + { status: 400 } + ) + } + + const params = validationResult.data + + if (params.actorId && !orgMemberIds.includes(params.actorId)) { + return NextResponse.json( + { error: 'actorId is not a member of your organization' }, + { status: 400 } + ) + } + + let scopeCondition: SQL + + if (params.includeDeparted) { + const orgWorkspaces = await db + .select({ id: workspace.id }) + .from(workspace) + .where(inArray(workspace.ownerId, orgMemberIds)) + + const orgWorkspaceIds = orgWorkspaces.map((w) => w.id) + + if (orgWorkspaceIds.length > 0) { + scopeCondition = or( + inArray(auditLog.actorId, orgMemberIds), + inArray(auditLog.workspaceId, orgWorkspaceIds) + )! + } else { + scopeCondition = inArray(auditLog.actorId, orgMemberIds) + } + } else { + scopeCondition = inArray(auditLog.actorId, orgMemberIds) + } + + const conditions: SQL[] = [scopeCondition] + + if (params.action) conditions.push(eq(auditLog.action, params.action)) + if (params.resourceType) conditions.push(eq(auditLog.resourceType, params.resourceType)) + if (params.resourceId) conditions.push(eq(auditLog.resourceId, params.resourceId)) + if (params.workspaceId) conditions.push(eq(auditLog.workspaceId, params.workspaceId)) + if (params.actorId) conditions.push(eq(auditLog.actorId, params.actorId)) + if (params.startDate) conditions.push(gte(auditLog.createdAt, new Date(params.startDate))) + if (params.endDate) conditions.push(lte(auditLog.createdAt, new Date(params.endDate))) + + if (params.cursor) { + const cursorData = decodeCursor(params.cursor) + if (cursorData?.createdAt && cursorData.id) { + const cursorDate = new Date(cursorData.createdAt) + if (!Number.isNaN(cursorDate.getTime())) { + conditions.push( + or( + lt(auditLog.createdAt, cursorDate), + and(eq(auditLog.createdAt, cursorDate), lt(auditLog.id, cursorData.id)) + )! + ) + } + } + } + + const rows = await db + .select() + .from(auditLog) + .where(and(...conditions)) + .orderBy(desc(auditLog.createdAt), desc(auditLog.id)) + .limit(params.limit + 1) + + const hasMore = rows.length > params.limit + const data = rows.slice(0, params.limit) + + let nextCursor: string | undefined + if (hasMore && data.length > 0) { + const last = data[data.length - 1] + nextCursor = encodeCursor({ + createdAt: last.createdAt.toISOString(), + id: last.id, + }) + } + + const formattedLogs = data.map(formatAuditLogEntry) + + const limits = await getUserLimits(userId) + const response = createApiResponse({ data: formattedLogs, nextCursor }, limits, rateLimit) + + return NextResponse.json(response.body, { headers: response.headers }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error(`[${requestId}] Audit logs fetch error`, { error: message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 06b4109433..60a7b93474 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -19,7 +19,7 @@ export interface RateLimitResult { export async function checkRateLimit( request: NextRequest, - endpoint: 'logs' | 'logs-detail' | 'workflows' | 'workflow-detail' = 'logs' + endpoint: 'logs' | 'logs-detail' | 'workflows' | 'workflow-detail' | 'audit-logs' = 'logs' ): Promise { try { const auth = await authenticateV1Request(request) From 2c7aee8e2ceb29e3d385d1b86048cfecdffd1418 Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 25 Feb 2026 14:11:58 -0800 Subject: [PATCH 09/12] unified list of languages for google translate --- apps/sim/blocks/blocks/google_translate.ts | 195 +++++++++++++++------ 1 file changed, 140 insertions(+), 55 deletions(-) diff --git a/apps/sim/blocks/blocks/google_translate.ts b/apps/sim/blocks/blocks/google_translate.ts index 881feea0e5..81815f890f 100644 --- a/apps/sim/blocks/blocks/google_translate.ts +++ b/apps/sim/blocks/blocks/google_translate.ts @@ -1,6 +1,142 @@ import { GoogleTranslateIcon } from '@/components/icons' import { AuthMode, type BlockConfig } from '@/blocks/types' +const SUPPORTED_LANGUAGES = [ + { label: 'Afrikaans', id: 'af' }, + { label: 'Albanian', id: 'sq' }, + { label: 'Amharic', id: 'am' }, + { label: 'Arabic', id: 'ar' }, + { label: 'Armenian', id: 'hy' }, + { label: 'Assamese', id: 'as' }, + { label: 'Aymara', id: 'ay' }, + { label: 'Azerbaijani', id: 'az' }, + { label: 'Bambara', id: 'bm' }, + { label: 'Basque', id: 'eu' }, + { label: 'Belarusian', id: 'be' }, + { label: 'Bengali', id: 'bn' }, + { label: 'Bhojpuri', id: 'bho' }, + { label: 'Bosnian', id: 'bs' }, + { label: 'Bulgarian', id: 'bg' }, + { label: 'Catalan', id: 'ca' }, + { label: 'Cebuano', id: 'ceb' }, + { label: 'Chinese (Simplified)', id: 'zh-CN' }, + { label: 'Chinese (Traditional)', id: 'zh-TW' }, + { label: 'Corsican', id: 'co' }, + { label: 'Croatian', id: 'hr' }, + { label: 'Czech', id: 'cs' }, + { label: 'Danish', id: 'da' }, + { label: 'Dhivehi', id: 'dv' }, + { label: 'Dogri', id: 'doi' }, + { label: 'Dutch', id: 'nl' }, + { label: 'English', id: 'en' }, + { label: 'Esperanto', id: 'eo' }, + { label: 'Estonian', id: 'et' }, + { label: 'Ewe', id: 'ee' }, + { label: 'Filipino', id: 'tl' }, + { label: 'Finnish', id: 'fi' }, + { label: 'French', id: 'fr' }, + { label: 'Frisian', id: 'fy' }, + { label: 'Galician', id: 'gl' }, + { label: 'Georgian', id: 'ka' }, + { label: 'German', id: 'de' }, + { label: 'Greek', id: 'el' }, + { label: 'Guarani', id: 'gn' }, + { label: 'Gujarati', id: 'gu' }, + { label: 'Haitian Creole', id: 'ht' }, + { label: 'Hausa', id: 'ha' }, + { label: 'Hawaiian', id: 'haw' }, + { label: 'Hebrew', id: 'he' }, + { label: 'Hindi', id: 'hi' }, + { label: 'Hmong', id: 'hmn' }, + { label: 'Hungarian', id: 'hu' }, + { label: 'Icelandic', id: 'is' }, + { label: 'Igbo', id: 'ig' }, + { label: 'Ilocano', id: 'ilo' }, + { label: 'Indonesian', id: 'id' }, + { label: 'Irish', id: 'ga' }, + { label: 'Italian', id: 'it' }, + { label: 'Japanese', id: 'ja' }, + { label: 'Javanese', id: 'jv' }, + { label: 'Kannada', id: 'kn' }, + { label: 'Kazakh', id: 'kk' }, + { label: 'Khmer', id: 'km' }, + { label: 'Kinyarwanda', id: 'rw' }, + { label: 'Konkani', id: 'gom' }, + { label: 'Korean', id: 'ko' }, + { label: 'Krio', id: 'kri' }, + { label: 'Kurdish', id: 'ku' }, + { label: 'Kurdish (Sorani)', id: 'ckb' }, + { label: 'Kyrgyz', id: 'ky' }, + { label: 'Lao', id: 'lo' }, + { label: 'Latin', id: 'la' }, + { label: 'Latvian', id: 'lv' }, + { label: 'Lingala', id: 'ln' }, + { label: 'Lithuanian', id: 'lt' }, + { label: 'Luganda', id: 'lg' }, + { label: 'Luxembourgish', id: 'lb' }, + { label: 'Macedonian', id: 'mk' }, + { label: 'Maithili', id: 'mai' }, + { label: 'Malagasy', id: 'mg' }, + { label: 'Malay', id: 'ms' }, + { label: 'Malayalam', id: 'ml' }, + { label: 'Maltese', id: 'mt' }, + { label: 'Maori', id: 'mi' }, + { label: 'Marathi', id: 'mr' }, + { label: 'Meiteilon (Manipuri)', id: 'mni-Mtei' }, + { label: 'Mizo', id: 'lus' }, + { label: 'Mongolian', id: 'mn' }, + { label: 'Myanmar (Burmese)', id: 'my' }, + { label: 'Nepali', id: 'ne' }, + { label: 'Norwegian', id: 'no' }, + { label: 'Nyanja (Chichewa)', id: 'ny' }, + { label: 'Odia (Oriya)', id: 'or' }, + { label: 'Oromo', id: 'om' }, + { label: 'Pashto', id: 'ps' }, + { label: 'Persian', id: 'fa' }, + { label: 'Polish', id: 'pl' }, + { label: 'Portuguese', id: 'pt' }, + { label: 'Punjabi', id: 'pa' }, + { label: 'Quechua', id: 'qu' }, + { label: 'Romanian', id: 'ro' }, + { label: 'Russian', id: 'ru' }, + { label: 'Samoan', id: 'sm' }, + { label: 'Sanskrit', id: 'sa' }, + { label: 'Scots Gaelic', id: 'gd' }, + { label: 'Sepedi', id: 'nso' }, + { label: 'Serbian', id: 'sr' }, + { label: 'Sesotho', id: 'st' }, + { label: 'Shona', id: 'sn' }, + { label: 'Sindhi', id: 'sd' }, + { label: 'Sinhala', id: 'si' }, + { label: 'Slovak', id: 'sk' }, + { label: 'Slovenian', id: 'sl' }, + { label: 'Somali', id: 'so' }, + { label: 'Spanish', id: 'es' }, + { label: 'Sundanese', id: 'su' }, + { label: 'Swahili', id: 'sw' }, + { label: 'Swedish', id: 'sv' }, + { label: 'Tajik', id: 'tg' }, + { label: 'Tamil', id: 'ta' }, + { label: 'Tatar', id: 'tt' }, + { label: 'Telugu', id: 'te' }, + { label: 'Thai', id: 'th' }, + { label: 'Tigrinya', id: 'ti' }, + { label: 'Tsonga', id: 'ts' }, + { label: 'Turkish', id: 'tr' }, + { label: 'Turkmen', id: 'tk' }, + { label: 'Twi (Akan)', id: 'ak' }, + { label: 'Ukrainian', id: 'uk' }, + { label: 'Urdu', id: 'ur' }, + { label: 'Uyghur', id: 'ug' }, + { label: 'Uzbek', id: 'uz' }, + { label: 'Vietnamese', id: 'vi' }, + { label: 'Welsh', id: 'cy' }, + { label: 'Xhosa', id: 'xh' }, + { label: 'Yiddish', id: 'yi' }, + { label: 'Yoruba', id: 'yo' }, + { label: 'Zulu', id: 'zu' }, +] as const + export const GoogleTranslateBlock: BlockConfig = { type: 'google_translate', name: 'Google Translate', @@ -35,42 +171,8 @@ export const GoogleTranslateBlock: BlockConfig = { title: 'Target Language', type: 'dropdown', condition: { field: 'operation', value: 'text' }, - options: [ - { label: 'English', id: 'en' }, - { label: 'Spanish', id: 'es' }, - { label: 'French', id: 'fr' }, - { label: 'German', id: 'de' }, - { label: 'Italian', id: 'it' }, - { label: 'Portuguese', id: 'pt' }, - { label: 'Russian', id: 'ru' }, - { label: 'Japanese', id: 'ja' }, - { label: 'Korean', id: 'ko' }, - { label: 'Chinese (Simplified)', id: 'zh-CN' }, - { label: 'Chinese (Traditional)', id: 'zh-TW' }, - { label: 'Arabic', id: 'ar' }, - { label: 'Hindi', id: 'hi' }, - { label: 'Turkish', id: 'tr' }, - { label: 'Dutch', id: 'nl' }, - { label: 'Polish', id: 'pl' }, - { label: 'Swedish', id: 'sv' }, - { label: 'Thai', id: 'th' }, - { label: 'Vietnamese', id: 'vi' }, - { label: 'Indonesian', id: 'id' }, - { label: 'Ukrainian', id: 'uk' }, - { label: 'Czech', id: 'cs' }, - { label: 'Greek', id: 'el' }, - { label: 'Hebrew', id: 'he' }, - { label: 'Romanian', id: 'ro' }, - { label: 'Hungarian', id: 'hu' }, - { label: 'Danish', id: 'da' }, - { label: 'Finnish', id: 'fi' }, - { label: 'Norwegian', id: 'no' }, - { label: 'Bengali', id: 'bn' }, - { label: 'Malay', id: 'ms' }, - { label: 'Filipino', id: 'tl' }, - { label: 'Swahili', id: 'sw' }, - { label: 'Urdu', id: 'ur' }, - ], + searchable: true, + options: SUPPORTED_LANGUAGES, value: () => 'es', required: { field: 'operation', value: 'text' }, }, @@ -79,25 +181,8 @@ export const GoogleTranslateBlock: BlockConfig = { title: 'Source Language', type: 'dropdown', condition: { field: 'operation', value: 'text' }, - options: [ - { label: 'Auto-detect', id: '' }, - { label: 'English', id: 'en' }, - { label: 'Spanish', id: 'es' }, - { label: 'French', id: 'fr' }, - { label: 'German', id: 'de' }, - { label: 'Italian', id: 'it' }, - { label: 'Portuguese', id: 'pt' }, - { label: 'Russian', id: 'ru' }, - { label: 'Japanese', id: 'ja' }, - { label: 'Korean', id: 'ko' }, - { label: 'Chinese (Simplified)', id: 'zh-CN' }, - { label: 'Chinese (Traditional)', id: 'zh-TW' }, - { label: 'Arabic', id: 'ar' }, - { label: 'Hindi', id: 'hi' }, - { label: 'Turkish', id: 'tr' }, - { label: 'Dutch', id: 'nl' }, - { label: 'Polish', id: 'pl' }, - ], + searchable: true, + options: [{ label: 'Auto-detect', id: '' }, ...SUPPORTED_LANGUAGES], value: () => '', }, { From 09bca7beed25c15e6e75a3c05cc8b6749e8f7162 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 25 Feb 2026 15:11:39 -0800 Subject: [PATCH 10/12] fix(workflow): respect snapshot view for panel lock toggle, remove unused disableAdmin prop --- .../w/[workflowId]/components/canvas-menu/canvas-menu.tsx | 1 - .../[workspaceId]/w/[workflowId]/components/panel/panel.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx index afbb5eda68..67c6971a05 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx @@ -32,7 +32,6 @@ export interface CanvasMenuProps { isChatOpen?: boolean hasClipboard?: boolean disableEdit?: boolean - disableAdmin?: boolean canAdmin?: boolean canUndo?: boolean canRedo?: boolean diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index df35a17fd9..e07976e4e6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -421,7 +421,7 @@ export const Panel = memo(function Panel() { Auto layout - {userPermissions.canAdmin && ( + {userPermissions.canAdmin && !currentWorkflow?.isSnapshotView && ( {allBlocksLocked ? ( From e1aa836d9adae5e9054d6b9d41641961bb24e0e9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 25 Feb 2026 15:14:00 -0800 Subject: [PATCH 11/12] improvement(canvas-menu): remove lock icon from workflow lock toggle --- .../w/[workflowId]/components/canvas-menu/canvas-menu.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx index 67c6971a05..a9c50bb21e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu/canvas-menu.tsx @@ -1,7 +1,6 @@ 'use client' import type { RefObject } from 'react' -import { Lock, Unlock } from 'lucide-react' import { Popover, PopoverAnchor, @@ -160,7 +159,6 @@ export function CanvasMenu({ onClose() }} > - {allBlocksLocked ? : } {allBlocksLocked ? 'Unlock workflow' : 'Lock workflow'} )} From 7ab7f96977bae9332252334fe9d0f87e19327fa9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 25 Feb 2026 15:20:08 -0800 Subject: [PATCH 12/12] feat(audit): record audit log for workflow lock/unlock --- apps/sim/lib/audit/log.ts | 2 + apps/sim/socket/database/operations.ts | 51 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/apps/sim/lib/audit/log.ts b/apps/sim/lib/audit/log.ts index a4300b82bd..af3cf23bcd 100644 --- a/apps/sim/lib/audit/log.ts +++ b/apps/sim/lib/audit/log.ts @@ -131,6 +131,8 @@ export const AuditAction = { WORKFLOW_DUPLICATED: 'workflow.duplicated', WORKFLOW_DEPLOYMENT_ACTIVATED: 'workflow.deployment_activated', WORKFLOW_DEPLOYMENT_REVERTED: 'workflow.deployment_reverted', + WORKFLOW_LOCKED: 'workflow.locked', + WORKFLOW_UNLOCKED: 'workflow.unlocked', WORKFLOW_VARIABLES_UPDATED: 'workflow.variables_updated', // Workspaces diff --git a/apps/sim/socket/database/operations.ts b/apps/sim/socket/database/operations.ts index d677466cba..26ed43a325 100644 --- a/apps/sim/socket/database/operations.ts +++ b/apps/sim/socket/database/operations.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq, inArray, or, sql } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { env } from '@/lib/core/config/env' import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' @@ -207,6 +208,17 @@ export async function persistWorkflowOperation(workflowId: string, operation: an } }) + // Audit workflow-level lock/unlock operations + if ( + target === OPERATION_TARGETS.BLOCKS && + op === BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED && + userId + ) { + auditWorkflowLockToggle(workflowId, userId).catch((error) => { + logger.error('Failed to audit workflow lock toggle', { error, workflowId }) + }) + } + const duration = Date.now() - startTime if (duration > 100) { logger.warn('Slow socket DB operation:', { @@ -226,6 +238,45 @@ export async function persistWorkflowOperation(workflowId: string, operation: an } } +/** + * Records an audit log entry when all blocks in a workflow are locked or unlocked. + * Only audits workflow-level transitions (all locked or all unlocked), not partial toggles. + */ +async function auditWorkflowLockToggle(workflowId: string, actorId: string): Promise { + const [wf] = await db + .select({ name: workflow.name, workspaceId: workflow.workspaceId }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + + if (!wf) return + + const blocks = await db + .select({ locked: workflowBlocks.locked }) + .from(workflowBlocks) + .where(eq(workflowBlocks.workflowId, workflowId)) + + if (blocks.length === 0) return + + const allLocked = blocks.every((b) => b.locked) + const allUnlocked = blocks.every((b) => !b.locked) + + // Only audit workflow-level transitions, not partial toggles + if (!allLocked && !allUnlocked) return + + recordAudit({ + workspaceId: wf.workspaceId, + actorId, + action: allLocked ? AuditAction.WORKFLOW_LOCKED : AuditAction.WORKFLOW_UNLOCKED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowId, + resourceName: wf.name, + description: allLocked + ? `Locked workflow "${wf.name}"` + : `Unlocked workflow "${wf.name}"`, + metadata: { blockCount: blocks.length }, + }) +} + async function handleBlockOperationTx( tx: any, workflowId: string,