Compare commits

..

26 Commits

Author SHA1 Message Date
waleed
a826b9785d refactor(workflow): use pre-computed lock state from contextMenuBlocks
contextMenuBlocks already has locked and isParentLocked properties
computed in use-canvas-context-menu.ts, so there's no need to look
up blocks again via hasProtectedBlocks.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 21:15:33 -08:00
waleed
813ec9b758 fix(socket): add comprehensive lock validation across operations
Based on audit findings, adds lock validation to multiple operations:

1. BATCH_TOGGLE_HANDLES - now skips locked/protected blocks at:
   - Store layer (batchToggleHandles)
   - Collaborative hook (collaborativeBatchToggleBlockHandles)
   - Server socket handler

2. BATCH_ADD_BLOCKS - server now filters blocks being added to
   locked parent containers

3. BATCH_UPDATE_PARENT - server now:
   - Skips protected blocks (locked or inside locked container)
   - Prevents moving blocks into locked containers

All validations use consistent isProtected() helper that checks both
direct lock and parent container lock.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 21:12:50 -08:00
waleed
52d9f31621 fix(undo-redo): use consistent target state for toggle redo
The redo logic for BATCH_TOGGLE_ENABLED and BATCH_TOGGLE_LOCKED was
incorrectly computing each block's new state as !previousStates[blockId].
However, the store's batchToggleEnabled/batchToggleLocked set ALL blocks
to the SAME target state based on the first block's previous state.

Now redo computes targetState = !previousStates[firstBlockId] and applies
it to all blocks, matching the store's behavior.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 21:01:55 -08:00
waleed
3fbcfc662c test(socket): update permission test for admin-only lock toggle
batch-toggle-locked is now admin-only, so write role should be denied.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 20:49:07 -08:00
waleed
3664a56fca fix(socket): add server-side lock validation and admin-only permissions
1. BATCH_TOGGLE_LOCKED now requires admin role - non-admin users with
   write role can no longer bypass UI restriction via direct socket
   messages

2. BATCH_REMOVE_BLOCKS now validates lock status server-side - filters
   out protected blocks (locked or inside locked parent) before deletion

3. Remove duplicate/outdated comment in regenerateBlockIds

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 20:45:40 -08:00
waleed
ef4acfdf39 fix(copilot): check parent lock in edit and delete operations
Both edit and delete operations now check if the block's parent
container is locked, not just if the block itself is locked. This
ensures consistent behavior with the UI which uses isBlockProtected
utility that checks both direct lock and parent lock.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 20:34:00 -08:00
waleed
395e6ed591 fix(lock): fix toggle locked target state and draggable check
1. BATCH_TOGGLE_LOCKED now uses first block from blocksToToggle set
   instead of blockIds[0], matching BATCH_TOGGLE_ENABLED pattern.
   Also added early exit if blocksToToggle is empty.

2. Blocks inside locked containers are now properly non-draggable.
   Changed draggable check from !block.locked to use isBlockProtected()
   which checks both block lock and parent container lock.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 20:29:14 -08:00
waleed
0eea69b07d fix(lock): prevent duplicates inside locked containers via regenerateBlockIds
1. regenerateBlockIds now checks if existing parent is locked before
   keeping the block inside it. If parent is locked, the duplicate
   is placed outside (parentId cleared) instead of creating an
   inconsistent state.

2. Remove unnecessary effectivePermissions.canAdmin and potentialParentId
   from onNodeDragStart dependency array.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 20:17:35 -08:00
waleed
802884f814 fix(copilot): add lock checks for insert and extract operations
- insert_into_subflow: Check if existing block being moved is locked
- extract_from_subflow: Check if block or parent subflow is locked

These operations now match the UI behavior where locked blocks
cannot be moved into/out of containers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 20:04:06 -08:00
waleed
4c05ae1f34 fix(lock): address review comments for lock feature
1. Store batchToggleEnabled now uses continue to skip locked blocks
   entirely, matching database operation behavior

2. Copilot add operation now checks if parent container is locked
   before adding nested nodes (defensive check for consistency)

3. Remove unused filterUnprotectedEdges function

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:56:00 -08:00
waleed
8dad4d43b2 refactor(workflow): extend block protection utilities for edge protection
Add isEdgeProtected, filterUnprotectedEdges, and hasProtectedBlocks
utilities. Refactor workflow.tsx to use these helpers for:
- onEdgesChange edge removal filtering
- onConnect connection prevention
- onNodeDragStart drag prevention
- Keyboard edge deletion
- Block menu disableEdit calculation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:41:22 -08:00
waleed
c987b6ff6d refactor(workflow): extract block deletion protection into shared utility
Extract duplicated block protection logic from workflow.tsx into
a reusable filterProtectedBlocks helper in utils/block-protection-utils.ts.
This ensures consistent behavior between context menu delete and
keyboard delete operations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:35:01 -08:00
waleed
901bffe44c fix(block-menu): paste should not be disabled for locked selection
Paste creates new blocks, doesn't modify selected ones. Changed from
disableEdit (includes lock state) to !userCanEdit (permission only),
matching the Duplicate action behavior.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:28:49 -08:00
waleed
7714dad8ab add lock block image 2026-01-31 19:16:34 -08:00
waleed
ab4b09c484 remove prefix square brackets in error notif 2026-01-31 19:12:21 -08:00
waleed
ee9f2e33c9 docs(quick-reference): add lock block action
Added documentation for the lock/unlock block feature (admin only).
Note: Image placeholder added, pending actual screenshot.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:06:34 -08:00
waleed
da5e0aa07d fix(enable): consistent behavior - can't enable if parent disabled
Same pattern as lock: must enable parent container first before
enabling children inside it.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:05:14 -08:00
waleed
ecb13a58d6 fix(lock): ensure consistent behavior across all UIs
Block Menu, Editor, Action Bar now all have identical behavior:
- Enable/Disable: disabled when locked OR parent locked
- Flip Handles: disabled when locked OR parent locked
- Delete: disabled when locked OR parent locked
- Remove from Subflow: disabled when locked OR parent locked
- Lock: always available for admins
- Unlock: disabled when parent is locked (unlock parent first)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:02:03 -08:00
waleed
bd36283d94 fix(lock): prevent unlocking blocks inside locked containers
- Editor: can't unlock block if parent container is locked
- Action bar: can't unlock block if parent container is locked
- Shows "Parent container is locked" tooltip in both cases

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 18:59:15 -08:00
waleed
73856af86d fix(lock): address code review feedback
- Fix toggle enabled using first toggleable block, not first block
- Delete button now checks isParentLocked
- Lock button now has disabled state
- Editor lock icon distinguishes block vs parent lock state

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 18:56:57 -08:00
waleed
3f908d6121 fix code block disabled state, allow unlock from editor 2026-01-31 18:45:42 -08:00
waleed
c19263e25f fix(duplicate): unlock all blocks when duplicating workflow
- Server-side workflow duplication now sets locked: false for all blocks
- regenerateWorkflowStateIds also unlocks blocks for templates
- Client-side regenerateBlockIds already handled this (for paste/import)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 18:42:06 -08:00
waleed
63eba0f6fb fix(duplicate): place duplicate outside locked container
When duplicating a block that's inside a locked loop/parallel,
the duplicate is now placed outside the container since nothing
should be added to a locked container.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 18:40:21 -08:00
waleed
4f342d38b0 Merge origin/staging into feat/lock
Resolved conflicts:
- Removed addBlock from store (now using batchAddBlocks)
- Updated lock tests to use test helper addBlock function
- Kept both staging's optimization tests and lock feature tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 18:02:03 -08:00
waleed
6907c88736 unlock duplicates of locked blocks 2026-01-31 17:33:02 -08:00
waleed
d533ea27e1 feat(canvas): added the ability to lock blocks 2026-01-31 15:51:14 -08:00
93 changed files with 12254 additions and 6592 deletions

View File

@@ -5421,18 +5421,3 @@ z'
</svg> </svg>
) )
} }
export function EnrichSoIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 398 394' fill='none'>
<path
fill='#5A52F4'
d='M129.705566,319.705719 C127.553314,322.684906 125.651512,325.414673 123.657059,328.277466 C113.748466,318.440308 105.605003,310.395905 97.510834,302.302216 C93.625801,298.417419 89.990181,294.269318 85.949242,290.558868 C82.857994,287.720428 82.464081,285.757660 85.772888,282.551880 C104.068108,264.826202 122.146088,246.876312 140.285110,228.989670 C141.183945,228.103317 141.957443,227.089844 143.588837,225.218384 C140.691605,225.066116 138.820053,224.882874 136.948410,224.881958 C102.798264,224.865326 68.647453,224.765244 34.498699,224.983612 C29.315699,225.016739 27.990419,223.343155 28.090912,218.397430 C28.381887,204.076935 28.189890,189.746719 28.195684,175.420319 C28.198524,168.398178 28.319166,168.279541 35.590389,168.278687 C69.074188,168.274780 102.557991,168.281174 136.041794,168.266083 C137.968231,168.265213 139.894608,168.107101 141.821030,168.022171 C142.137955,167.513992 142.454895,167.005829 142.771820,166.497650 C122.842415,146.495621 102.913002,126.493591 83.261360,106.770348 C96.563828,93.471756 109.448814,80.590523 122.656265,67.386925 C123.522743,68.161835 124.785545,69.187096 125.930321,70.330513 C144.551819,88.930206 163.103683,107.600082 181.805267,126.118790 C186.713593,130.979126 189.085648,136.448059 189.055374,143.437057 C188.899490,179.418961 188.911179,215.402191 189.046661,251.384262 C189.072296,258.190796 186.742920,263.653717 181.982727,268.323273 C164.624405,285.351227 147.295807,302.409485 129.705566,319.705719z'
/>
<path
fill='#5A52F4'
d='M276.070923,246.906128 C288.284363,258.985870 300.156097,270.902100 312.235931,282.603485 C315.158752,285.434784 315.417542,287.246246 312.383484,290.248932 C301.143494,301.372498 290.168549,312.763733 279.075592,324.036255 C278.168030,324.958496 277.121307,325.743835 275.898315,326.801086 C274.628357,325.711792 273.460663,324.822968 272.422150,323.802673 C253.888397,305.594757 235.418701,287.321289 216.818268,269.181854 C211.508789,264.003937 208.872726,258.136688 208.914001,250.565842 C209.108337,214.917786 209.084808,179.267715 208.928864,143.619293 C208.898407,136.654907 211.130066,131.122162 216.052216,126.246094 C234.867538,107.606842 253.537521,88.820908 272.274780,70.102730 C273.313202,69.065353 274.468597,68.145027 275.264038,67.440727 C288.353516,80.579514 301.213470,93.487869 314.597534,106.922356 C295.163391,126.421753 275.214752,146.437363 255.266113,166.452972 C255.540176,166.940353 255.814240,167.427734 256.088318,167.915100 C257.983887,168.035736 259.879425,168.260345 261.775085,168.261551 C295.425201,168.282852 329.075287,168.273544 362.725403,168.279831 C369.598907,168.281113 369.776215,168.463593 369.778931,175.252213 C369.784882,189.911667 369.646088,204.573074 369.861206,219.229355 C369.925110,223.585022 368.554596,224.976288 364.148865,224.956406 C329.833130,224.801605 295.516388,224.869598 261.199951,224.868744 C259.297974,224.868698 257.396027,224.868744 254.866638,224.868744 C262.350708,232.658707 269.078217,239.661194 276.070923,246.906128z'
/>
</svg>
)
}

View File

@@ -29,7 +29,6 @@ import {
DynamoDBIcon, DynamoDBIcon,
ElasticsearchIcon, ElasticsearchIcon,
ElevenLabsIcon, ElevenLabsIcon,
EnrichSoIcon,
ExaAIIcon, ExaAIIcon,
EyeIcon, EyeIcon,
FirecrawlIcon, FirecrawlIcon,
@@ -161,7 +160,6 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
dynamodb: DynamoDBIcon, dynamodb: DynamoDBIcon,
elasticsearch: ElasticsearchIcon, elasticsearch: ElasticsearchIcon,
elevenlabs: ElevenLabsIcon, elevenlabs: ElevenLabsIcon,
enrich: EnrichSoIcon,
exa: ExaAIIcon, exa: ExaAIIcon,
file_v2: DocumentIcon, file_v2: DocumentIcon,
firecrawl: FirecrawlIcon, firecrawl: FirecrawlIcon,

View File

@@ -180,6 +180,11 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
<td>Right-click → **Enable/Disable**</td> <td>Right-click → **Enable/Disable**</td>
<td><ActionImage src="/static/quick-reference/disable-block.png" alt="Disable block" /></td> <td><ActionImage src="/static/quick-reference/disable-block.png" alt="Disable block" /></td>
</tr> </tr>
<tr>
<td>Lock/Unlock a block</td>
<td>Hover block → Click lock icon (Admin only)</td>
<td><ActionImage src="/static/quick-reference/lock-block.png" alt="Lock block" /></td>
</tr>
<tr> <tr>
<td>Toggle handle orientation</td> <td>Toggle handle orientation</td>
<td>Right-click → **Toggle Handles**</td> <td>Right-click → **Toggle Handles**</td>

View File

@@ -1,930 +0,0 @@
---
title: Enrich
description: B2B data enrichment and LinkedIn intelligence with Enrich.so
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="enrich"
color="#E5E5E6"
/>
{/* MANUAL-CONTENT-START:intro */}
[Enrich.so](https://enrich.so/) delivers real-time, precision B2B data enrichment and LinkedIn intelligence. Its platform provides dynamic access to public and structured company, contact, and professional information, enabling teams to build richer profiles, improve lead quality, and drive more effective outreach.
With Enrich.so, you can:
- **Enrich contact and company profiles**: Instantly discover key data points for leads, prospects, and businesses using just an email or LinkedIn profile.
- **Verify email deliverability**: Check if emails are valid, deliverable, and safe to contact before sending.
- **Find work & personal emails**: Identify missing business emails from a LinkedIn profile or personal emails to expand your reach.
- **Reveal phone numbers and social profiles**: Surface additional communication channels for contacts through enrichment tools.
- **Analyze LinkedIn posts and engagement**: Extract insights on post reach, reactions, and audience from public LinkedIn content.
- **Conduct advanced people and company search**: Enable your agents to locate companies and professionals based on deep filters and real-time intelligence.
The Sim integration with Enrich.so empowers your agents and automations to instantly query, enrich, and validate B2B data, boosting productivity in workflows like sales prospecting, recruiting, marketing operations, and more. Combining Sim's orchestration capabilities with Enrich.so unlocks smarter, data-driven automation strategies powered by best-in-class B2B intelligence.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Access real-time B2B data intelligence with Enrich.so. Enrich profiles from email addresses, find work emails from LinkedIn, verify email deliverability, search for people and companies, and analyze LinkedIn post engagement.
## Tools
### `enrich_check_credits`
Check your Enrich API credit usage and remaining balance.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalCredits` | number | Total credits allocated to the account |
| `creditsUsed` | number | Credits consumed so far |
| `creditsRemaining` | number | Available credits remaining |
### `enrich_email_to_profile`
Retrieve detailed LinkedIn profile information using an email address including work history, education, and skills.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `email` | string | Yes | Email address to look up \(e.g., john.doe@company.com\) |
| `inRealtime` | boolean | No | Set to true to retrieve fresh data, bypassing cached information |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `displayName` | string | Full display name |
| `firstName` | string | First name |
| `lastName` | string | Last name |
| `headline` | string | Professional headline |
| `occupation` | string | Current occupation |
| `summary` | string | Profile summary |
| `location` | string | Location |
| `country` | string | Country |
| `linkedInUrl` | string | LinkedIn profile URL |
| `photoUrl` | string | Profile photo URL |
| `connectionCount` | number | Number of connections |
| `isConnectionCountObfuscated` | boolean | Whether connection count is obfuscated \(500+\) |
| `positionHistory` | array | Work experience history |
| ↳ `title` | string | Job title |
| ↳ `company` | string | Company name |
| ↳ `startDate` | string | Start date |
| ↳ `endDate` | string | End date |
| ↳ `location` | string | Location |
| `education` | array | Education history |
| ↳ `school` | string | School name |
| ↳ `degree` | string | Degree |
| ↳ `fieldOfStudy` | string | Field of study |
| ↳ `startDate` | string | Start date |
| ↳ `endDate` | string | End date |
| `certifications` | array | Professional certifications |
| ↳ `name` | string | Certification name |
| ↳ `authority` | string | Issuing authority |
| ↳ `url` | string | Certification URL |
| `skills` | array | List of skills |
| `languages` | array | List of languages |
| `locale` | string | Profile locale \(e.g., en_US\) |
| `version` | number | Profile version number |
### `enrich_email_to_person_lite`
Retrieve basic LinkedIn profile information from an email address. A lighter version with essential data only.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `email` | string | Yes | Email address to look up \(e.g., john.doe@company.com\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `name` | string | Full name |
| `firstName` | string | First name |
| `lastName` | string | Last name |
| `email` | string | Email address |
| `title` | string | Job title |
| `location` | string | Location |
| `company` | string | Current company |
| `companyLocation` | string | Company location |
| `companyLinkedIn` | string | Company LinkedIn URL |
| `profileId` | string | LinkedIn profile ID |
| `schoolName` | string | School name |
| `schoolUrl` | string | School URL |
| `linkedInUrl` | string | LinkedIn profile URL |
| `photoUrl` | string | Profile photo URL |
| `followerCount` | number | Number of followers |
| `connectionCount` | number | Number of connections |
| `languages` | array | Languages spoken |
| `projects` | array | Projects |
| `certifications` | array | Certifications |
| `volunteerExperience` | array | Volunteer experience |
### `enrich_linkedin_profile`
Enrich a LinkedIn profile URL with detailed information including positions, education, and social metrics.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `url` | string | Yes | LinkedIn profile URL \(e.g., linkedin.com/in/williamhgates\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `profileId` | string | LinkedIn profile ID |
| `firstName` | string | First name |
| `lastName` | string | Last name |
| `subTitle` | string | Profile subtitle/headline |
| `profilePicture` | string | Profile picture URL |
| `backgroundImage` | string | Background image URL |
| `industry` | string | Industry |
| `location` | string | Location |
| `followersCount` | number | Number of followers |
| `connectionsCount` | number | Number of connections |
| `premium` | boolean | Whether the account is premium |
| `influencer` | boolean | Whether the account is an influencer |
| `positions` | array | Work positions |
| ↳ `title` | string | Job title |
| ↳ `company` | string | Company name |
| ↳ `companyLogo` | string | Company logo URL |
| ↳ `startDate` | string | Start date |
| ↳ `endDate` | string | End date |
| ↳ `location` | string | Location |
| `education` | array | Education history |
| ↳ `school` | string | School name |
| ↳ `degree` | string | Degree |
| ↳ `fieldOfStudy` | string | Field of study |
| ↳ `startDate` | string | Start date |
| ↳ `endDate` | string | End date |
| `websites` | array | Personal websites |
### `enrich_find_email`
Find a person
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `fullName` | string | Yes | Person's full name \(e.g., John Doe\) |
| `companyDomain` | string | Yes | Company domain \(e.g., example.com\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `email` | string | Found email address |
| `firstName` | string | First name |
| `lastName` | string | Last name |
| `domain` | string | Company domain |
| `found` | boolean | Whether an email was found |
| `acceptAll` | boolean | Whether the domain accepts all emails |
### `enrich_linkedin_to_work_email`
Find a work email address from a LinkedIn profile URL.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `linkedinProfile` | string | Yes | LinkedIn profile URL \(e.g., https://www.linkedin.com/in/williamhgates\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `email` | string | Found work email address |
| `found` | boolean | Whether an email was found |
| `status` | string | Request status \(in_progress or completed\) |
### `enrich_linkedin_to_personal_email`
Find personal email address from a LinkedIn profile URL.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `linkedinProfile` | string | Yes | LinkedIn profile URL \(e.g., linkedin.com/in/username\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `email` | string | Personal email address |
| `found` | boolean | Whether an email was found |
| `status` | string | Request status |
### `enrich_phone_finder`
Find a phone number from a LinkedIn profile URL.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `linkedinProfile` | string | Yes | LinkedIn profile URL \(e.g., linkedin.com/in/williamhgates\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `profileUrl` | string | LinkedIn profile URL |
| `mobileNumber` | string | Found mobile phone number |
| `found` | boolean | Whether a phone number was found |
| `status` | string | Request status \(in_progress or completed\) |
### `enrich_email_to_phone`
Find a phone number associated with an email address.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `email` | string | Yes | Email address to look up \(e.g., john.doe@example.com\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `email` | string | Email address looked up |
| `mobileNumber` | string | Found mobile phone number |
| `found` | boolean | Whether a phone number was found |
| `status` | string | Request status \(in_progress or completed\) |
### `enrich_verify_email`
Verify an email address for deliverability, including catch-all detection and provider identification.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `email` | string | Yes | Email address to verify \(e.g., john.doe@example.com\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `email` | string | Email address verified |
| `status` | string | Verification status |
| `result` | string | Deliverability result \(deliverable, undeliverable, etc.\) |
| `confidenceScore` | number | Confidence score \(0-100\) |
| `smtpProvider` | string | Email service provider \(e.g., Google, Microsoft\) |
| `mailDisposable` | boolean | Whether the email is from a disposable provider |
| `mailAcceptAll` | boolean | Whether the domain is a catch-all domain |
| `free` | boolean | Whether the email uses a free email service |
### `enrich_disposable_email_check`
Check if an email address is from a disposable or temporary email provider. Returns a score and validation details.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `email` | string | Yes | Email address to check \(e.g., john.doe@example.com\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `email` | string | Email address checked |
| `score` | number | Validation score \(0-100\) |
| `testsPassed` | string | Number of tests passed \(e.g., "3/3"\) |
| `passed` | boolean | Whether the email passed all validation tests |
| `reason` | string | Reason for failure if email did not pass |
| `mailServerIp` | string | Mail server IP address |
| `mxRecords` | array | MX records for the domain |
| ↳ `host` | string | MX record host |
| ↳ `pref` | number | MX record preference |
### `enrich_email_to_ip`
Discover an IP address associated with an email address.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `email` | string | Yes | Email address to look up \(e.g., john.doe@example.com\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `email` | string | Email address looked up |
| `ip` | string | Associated IP address |
| `found` | boolean | Whether an IP address was found |
### `enrich_ip_to_company`
Identify a company from an IP address with detailed firmographic information.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `ip` | string | Yes | IP address to look up \(e.g., 86.92.60.221\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `name` | string | Company name |
| `legalName` | string | Legal company name |
| `domain` | string | Primary domain |
| `domainAliases` | array | Domain aliases |
| `sector` | string | Business sector |
| `industry` | string | Industry |
| `phone` | string | Phone number |
| `employees` | number | Number of employees |
| `revenue` | string | Estimated revenue |
| `location` | json | Company location |
| ↳ `city` | string | City |
| ↳ `state` | string | State |
| ↳ `country` | string | Country |
| ↳ `timezone` | string | Timezone |
| `linkedInUrl` | string | LinkedIn company URL |
| `twitterUrl` | string | Twitter URL |
| `facebookUrl` | string | Facebook URL |
### `enrich_company_lookup`
Look up comprehensive company information by name or domain including funding, location, and social profiles.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `name` | string | No | Company name \(e.g., Google\) |
| `domain` | string | No | Company domain \(e.g., google.com\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `name` | string | Company name |
| `universalName` | string | Universal company name |
| `companyId` | string | Company ID |
| `description` | string | Company description |
| `phone` | string | Phone number |
| `linkedInUrl` | string | LinkedIn company URL |
| `websiteUrl` | string | Company website |
| `followers` | number | Number of LinkedIn followers |
| `staffCount` | number | Number of employees |
| `foundedDate` | string | Date founded |
| `type` | string | Company type |
| `industries` | array | Industries |
| `specialties` | array | Company specialties |
| `headquarters` | json | Headquarters location |
| ↳ `city` | string | City |
| ↳ `country` | string | Country |
| ↳ `postalCode` | string | Postal code |
| ↳ `line1` | string | Address line 1 |
| `logo` | string | Company logo URL |
| `coverImage` | string | Cover image URL |
| `fundingRounds` | array | Funding history |
| ↳ `roundType` | string | Funding round type |
| ↳ `amount` | number | Amount raised |
| ↳ `currency` | string | Currency |
| ↳ `investors` | array | Investors |
### `enrich_company_funding`
Retrieve company funding history, traffic metrics, and executive information by domain.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `domain` | string | Yes | Company domain \(e.g., example.com\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `legalName` | string | Legal company name |
| `employeeCount` | number | Number of employees |
| `headquarters` | string | Headquarters location |
| `industry` | string | Industry |
| `totalFundingRaised` | number | Total funding raised |
| `fundingRounds` | array | Funding rounds |
| ↳ `roundType` | string | Round type |
| ↳ `amount` | number | Amount raised |
| ↳ `date` | string | Date |
| ↳ `investors` | array | Investors |
| `monthlyVisits` | number | Monthly website visits |
| `trafficChange` | number | Traffic change percentage |
| `itSpending` | number | Estimated IT spending in USD |
| `executives` | array | Executive team |
| ↳ `name` | string | Name |
| ↳ `title` | string | Title |
### `enrich_company_revenue`
Retrieve company revenue data, CEO information, and competitive analysis by domain.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `domain` | string | Yes | Company domain \(e.g., clay.io\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `companyName` | string | Company name |
| `shortDescription` | string | Short company description |
| `fullSummary` | string | Full company summary |
| `revenue` | string | Company revenue |
| `revenueMin` | number | Minimum revenue estimate |
| `revenueMax` | number | Maximum revenue estimate |
| `employeeCount` | number | Number of employees |
| `founded` | string | Year founded |
| `ownership` | string | Ownership type |
| `status` | string | Company status \(e.g., Active\) |
| `website` | string | Company website URL |
| `ceo` | json | CEO information |
| ↳ `name` | string | CEO name |
| ↳ `designation` | string | CEO designation/title |
| ↳ `rating` | number | CEO rating |
| `socialLinks` | json | Social media links |
| ↳ `linkedIn` | string | LinkedIn URL |
| ↳ `twitter` | string | Twitter URL |
| ↳ `facebook` | string | Facebook URL |
| `totalFunding` | string | Total funding raised |
| `fundingRounds` | number | Number of funding rounds |
| `competitors` | array | Competitors |
| ↳ `name` | string | Competitor name |
| ↳ `revenue` | string | Revenue |
| ↳ `employeeCount` | number | Employee count |
| ↳ `headquarters` | string | Headquarters |
### `enrich_search_people`
Search for professionals by various criteria including name, title, skills, education, and company.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `firstName` | string | No | First name |
| `lastName` | string | No | Last name |
| `summary` | string | No | Professional summary keywords |
| `subTitle` | string | No | Job title/subtitle |
| `locationCountry` | string | No | Country |
| `locationCity` | string | No | City |
| `locationState` | string | No | State/province |
| `influencer` | boolean | No | Filter for influencers only |
| `premium` | boolean | No | Filter for premium accounts only |
| `language` | string | No | Primary language |
| `industry` | string | No | Industry |
| `currentJobTitles` | json | No | Current job titles \(array\) |
| `pastJobTitles` | json | No | Past job titles \(array\) |
| `skills` | json | No | Skills to search for \(array\) |
| `schoolNames` | json | No | School names \(array\) |
| `certifications` | json | No | Certifications to filter by \(array\) |
| `degreeNames` | json | No | Degree names to filter by \(array\) |
| `studyFields` | json | No | Fields of study to filter by \(array\) |
| `currentCompanies` | json | No | Current company IDs to filter by \(array of numbers\) |
| `pastCompanies` | json | No | Past company IDs to filter by \(array of numbers\) |
| `currentPage` | number | No | Page number \(default: 1\) |
| `pageSize` | number | No | Results per page \(default: 20\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `currentPage` | number | Current page number |
| `totalPage` | number | Total number of pages |
| `pageSize` | number | Results per page |
| `profiles` | array | Search results |
| ↳ `profileIdentifier` | string | Profile ID |
| ↳ `givenName` | string | First name |
| ↳ `familyName` | string | Last name |
| ↳ `currentPosition` | string | Current job title |
| ↳ `profileImage` | string | Profile image URL |
| ↳ `externalProfileUrl` | string | LinkedIn URL |
| ↳ `city` | string | City |
| ↳ `country` | string | Country |
| ↳ `expertSkills` | array | Skills |
### `enrich_search_company`
Search for companies by various criteria including name, industry, location, and size.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `name` | string | No | Company name |
| `website` | string | No | Company website URL |
| `tagline` | string | No | Company tagline |
| `type` | string | No | Company type \(e.g., Private, Public\) |
| `description` | string | No | Company description keywords |
| `industries` | json | No | Industries to filter by \(array\) |
| `locationCountry` | string | No | Country |
| `locationCity` | string | No | City |
| `postalCode` | string | No | Postal code |
| `locationCountryList` | json | No | Multiple countries to filter by \(array\) |
| `locationCityList` | json | No | Multiple cities to filter by \(array\) |
| `specialities` | json | No | Company specialties \(array\) |
| `followers` | number | No | Minimum number of followers |
| `staffCount` | number | No | Maximum staff count |
| `staffCountMin` | number | No | Minimum staff count |
| `staffCountMax` | number | No | Maximum staff count |
| `currentPage` | number | No | Page number \(default: 1\) |
| `pageSize` | number | No | Results per page \(default: 20\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `currentPage` | number | Current page number |
| `totalPage` | number | Total number of pages |
| `pageSize` | number | Results per page |
| `companies` | array | Search results |
| ↳ `companyName` | string | Company name |
| ↳ `tagline` | string | Company tagline |
| ↳ `webAddress` | string | Website URL |
| ↳ `industries` | array | Industries |
| ↳ `teamSize` | number | Team size |
| ↳ `linkedInProfile` | string | LinkedIn URL |
### `enrich_search_company_employees`
Search for employees within specific companies by location and job title.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `companyIds` | json | No | Array of company IDs to search within |
| `country` | string | No | Country filter \(e.g., United States\) |
| `city` | string | No | City filter \(e.g., San Francisco\) |
| `state` | string | No | State filter \(e.g., California\) |
| `jobTitles` | json | No | Job titles to filter by \(array\) |
| `page` | number | No | Page number \(default: 1\) |
| `pageSize` | number | No | Results per page \(default: 10\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `currentPage` | number | Current page number |
| `totalPage` | number | Total number of pages |
| `pageSize` | number | Number of results per page |
| `profiles` | array | Employee profiles |
| ↳ `profileIdentifier` | string | Profile ID |
| ↳ `givenName` | string | First name |
| ↳ `familyName` | string | Last name |
| ↳ `currentPosition` | string | Current job title |
| ↳ `profileImage` | string | Profile image URL |
| ↳ `externalProfileUrl` | string | LinkedIn URL |
| ↳ `city` | string | City |
| ↳ `country` | string | Country |
| ↳ `expertSkills` | array | Skills |
### `enrich_search_similar_companies`
Find companies similar to a given company by LinkedIn URL with filters for location and size.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `url` | string | Yes | LinkedIn company URL \(e.g., linkedin.com/company/google\) |
| `accountLocation` | json | No | Filter by locations \(array of country names\) |
| `employeeSizeType` | string | No | Employee size filter type \(e.g., RANGE\) |
| `employeeSizeRange` | json | No | Employee size ranges \(array of \{start, end\} objects\) |
| `page` | number | No | Page number \(default: 1\) |
| `num` | number | No | Number of results per page |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `companies` | array | Similar companies |
| ↳ `url` | string | LinkedIn URL |
| ↳ `name` | string | Company name |
| ↳ `universalName` | string | Universal name |
| ↳ `type` | string | Company type |
| ↳ `description` | string | Description |
| ↳ `phone` | string | Phone number |
| ↳ `website` | string | Website URL |
| ↳ `logo` | string | Logo URL |
| ↳ `foundedYear` | number | Year founded |
| ↳ `staffTotal` | number | Total staff |
| ↳ `industries` | array | Industries |
| ↳ `relevancyScore` | number | Relevancy score |
| ↳ `relevancyValue` | string | Relevancy value |
### `enrich_sales_pointer_people`
Advanced people search with complex filters for location, company size, seniority, experience, and more.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `page` | number | Yes | Page number \(starts at 1\) |
| `filters` | json | Yes | Array of filter objects. Each filter has type \(e.g., POSTAL_CODE, COMPANY_HEADCOUNT\), values \(array with id, text, selectionType: INCLUDED/EXCLUDED\), and optional selectedSubFilter |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `data` | array | People results |
| ↳ `name` | string | Full name |
| ↳ `summary` | string | Professional summary |
| ↳ `location` | string | Location |
| ↳ `profilePicture` | string | Profile picture URL |
| ↳ `linkedInUrn` | string | LinkedIn URN |
| ↳ `positions` | array | Work positions |
| ↳ `education` | array | Education |
| `pagination` | json | Pagination info |
| ↳ `totalCount` | number | Total results |
| ↳ `returnedCount` | number | Returned count |
| ↳ `start` | number | Start position |
| ↳ `limit` | number | Limit |
### `enrich_search_posts`
Search LinkedIn posts by keywords with date filtering.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `keywords` | string | Yes | Search keywords \(e.g., "AI automation"\) |
| `datePosted` | string | No | Time filter \(e.g., past_week, past_month\) |
| `page` | number | No | Page number \(default: 1\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `count` | number | Total number of results |
| `posts` | array | Search results |
| ↳ `url` | string | Post URL |
| ↳ `postId` | string | Post ID |
| ↳ `author` | object | Author information |
| ↳ `name` | string | Author name |
| ↳ `headline` | string | Author headline |
| ↳ `linkedInUrl` | string | Author LinkedIn URL |
| ↳ `profileImage` | string | Author profile image |
| ↳ `timestamp` | string | Post timestamp |
| ↳ `textContent` | string | Post text content |
| ↳ `hashtags` | array | Hashtags |
| ↳ `mediaUrls` | array | Media URLs |
| ↳ `reactions` | number | Number of reactions |
| ↳ `commentsCount` | number | Number of comments |
### `enrich_get_post_details`
Get detailed information about a LinkedIn post by URL.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `url` | string | Yes | LinkedIn post URL |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `postId` | string | Post ID |
| `author` | json | Author information |
| ↳ `name` | string | Author name |
| ↳ `headline` | string | Author headline |
| ↳ `linkedInUrl` | string | Author LinkedIn URL |
| ↳ `profileImage` | string | Author profile image |
| `timestamp` | string | Post timestamp |
| `textContent` | string | Post text content |
| `hashtags` | array | Hashtags |
| `mediaUrls` | array | Media URLs |
| `reactions` | number | Number of reactions |
| `commentsCount` | number | Number of comments |
### `enrich_search_post_reactions`
Get reactions on a LinkedIn post with filtering by reaction type.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `postUrn` | string | Yes | LinkedIn activity URN \(e.g., urn:li:activity:7231931952839196672\) |
| `reactionType` | string | Yes | Reaction type filter: all, like, love, celebrate, insightful, or funny \(default: all\) |
| `page` | number | Yes | Page number \(starts at 1\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `page` | number | Current page number |
| `totalPage` | number | Total number of pages |
| `count` | number | Number of reactions returned |
| `reactions` | array | Reactions |
| ↳ `reactionType` | string | Type of reaction |
| ↳ `reactor` | object | Person who reacted |
| ↳ `name` | string | Name |
| ↳ `subTitle` | string | Job title |
| ↳ `profileId` | string | Profile ID |
| ↳ `profilePicture` | string | Profile picture URL |
| ↳ `linkedInUrl` | string | LinkedIn URL |
### `enrich_search_post_comments`
Get comments on a LinkedIn post.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `postUrn` | string | Yes | LinkedIn activity URN \(e.g., urn:li:activity:7191163324208705536\) |
| `page` | number | No | Page number \(starts at 1, default: 1\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `page` | number | Current page number |
| `totalPage` | number | Total number of pages |
| `count` | number | Number of comments returned |
| `comments` | array | Comments |
| ↳ `activityId` | string | Comment activity ID |
| ↳ `commentary` | string | Comment text |
| ↳ `linkedInUrl` | string | Link to comment |
| ↳ `commenter` | object | Commenter info |
| ↳ `profileId` | string | Profile ID |
| ↳ `firstName` | string | First name |
| ↳ `lastName` | string | Last name |
| ↳ `subTitle` | string | Subtitle/headline |
| ↳ `profilePicture` | string | Profile picture URL |
| ↳ `backgroundImage` | string | Background image URL |
| ↳ `entityUrn` | string | Entity URN |
| ↳ `objectUrn` | string | Object URN |
| ↳ `profileType` | string | Profile type |
| ↳ `reactionBreakdown` | object | Reactions on the comment |
| ↳ `likes` | number | Number of likes |
| ↳ `empathy` | number | Number of empathy reactions |
| ↳ `other` | number | Number of other reactions |
### `enrich_search_people_activities`
Get a person
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `profileId` | string | Yes | LinkedIn profile ID |
| `activityType` | string | Yes | Activity type: posts, comments, or articles |
| `paginationToken` | string | No | Pagination token for next page of results |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `paginationToken` | string | Token for fetching next page |
| `activityType` | string | Type of activities returned |
| `activities` | array | Activities |
| ↳ `activityId` | string | Activity ID |
| ↳ `commentary` | string | Activity text content |
| ↳ `linkedInUrl` | string | Link to activity |
| ↳ `timeElapsed` | string | Time elapsed since activity |
| ↳ `numReactions` | number | Total number of reactions |
| ↳ `author` | object | Activity author info |
| ↳ `name` | string | Author name |
| ↳ `profileId` | string | Profile ID |
| ↳ `profilePicture` | string | Profile picture URL |
| ↳ `reactionBreakdown` | object | Reactions |
| ↳ `likes` | number | Likes |
| ↳ `empathy` | number | Empathy reactions |
| ↳ `other` | number | Other reactions |
| ↳ `attachments` | array | Attachment URLs |
### `enrich_search_company_activities`
Get a company
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `companyId` | string | Yes | LinkedIn company ID |
| `activityType` | string | Yes | Activity type: posts, comments, or articles |
| `paginationToken` | string | No | Pagination token for next page of results |
| `offset` | number | No | Number of records to skip \(default: 0\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `paginationToken` | string | Token for fetching next page |
| `activityType` | string | Type of activities returned |
| `activities` | array | Activities |
| ↳ `activityId` | string | Activity ID |
| ↳ `commentary` | string | Activity text content |
| ↳ `linkedInUrl` | string | Link to activity |
| ↳ `timeElapsed` | string | Time elapsed since activity |
| ↳ `numReactions` | number | Total number of reactions |
| ↳ `author` | object | Activity author info |
| ↳ `name` | string | Author name |
| ↳ `profileId` | string | Profile ID |
| ↳ `profilePicture` | string | Profile picture URL |
| ↳ `reactionBreakdown` | object | Reactions |
| ↳ `likes` | number | Likes |
| ↳ `empathy` | number | Empathy reactions |
| ↳ `other` | number | Other reactions |
| ↳ `attachments` | array | Attachments |
### `enrich_reverse_hash_lookup`
Convert an MD5 email hash back to the original email address and display name.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `hash` | string | Yes | MD5 hash value to look up |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `hash` | string | MD5 hash that was looked up |
| `email` | string | Original email address |
| `displayName` | string | Display name associated with the email |
| `found` | boolean | Whether an email was found for the hash |
### `enrich_search_logo`
Get a company logo image URL by domain.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Enrich API key |
| `url` | string | Yes | Company domain \(e.g., google.com\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `logoUrl` | string | URL to fetch the company logo |
| `domain` | string | Domain that was looked up |

View File

@@ -10,23 +10,6 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#181C1E" color="#181C1E"
/> />
{/* MANUAL-CONTENT-START:intro */}
[GitHub](https://github.com/) is the worlds leading platform for hosting, collaborating on, and managing source code. GitHub offers powerful tools for version control, code review, branching strategies, and team collaboration within the rich Git ecosystem, underpinning both open source and enterprise development worldwide.
The GitHub integration in Sim allows your agents to seamlessly automate, interact with, and orchestrate workflows across your repositories. Using this integration, agents can perform an extended set of code and collaboration operations, enabling:
- **Fetch pull request details:** Retrieve a full overview of any pull request, including file diffs, branch information, metadata, approvals, and a summary of changes, for automation or review workflows.
- **Create pull request comments:** Automatically generate or post comments on PRs—such as reviews, suggestions, or status updates—enabling speedy feedback, documentation, or policy enforcement.
- **Get repository information:** Access comprehensive repository metadata, including descriptions, visibility, topics, default branches, and contributors. This supports intelligent project analysis, dynamic workflow routing, and organizational reporting.
- **Fetch the latest commit:** Quickly obtain details from the newest commit on any branch, including hashes, messages, authors, and timestamps. This is useful for monitoring development velocity, triggering downstream actions, or enforcing quality checks.
- **Trigger workflows from GitHub events:** Set up Sim workflows to start automatically from key GitHub events, including pull request creation, review comments, or when new commits are pushed, through easy webhook integration. Automate actions such as deployments, notifications, compliance checks, or documentation updates in real time.
- **Monitor and manage repository activity:** Programmatically track contributions, manage PR review states, analyze branch histories, and audit code changes. Empower agents to enforce requirements, coordinate releases, and respond dynamically to development patterns.
- **Support for advanced automations:** Combine these operations—for example, fetch PR data, leave context-aware comments, and kick off multi-step Sim workflows on code pushes or PR merges—to automate your teams engineering processes from end to end.
By leveraging all of these capabilities, the Sim GitHub integration enables agents to engage deeply in the development lifecycle. Automate code reviews, streamline team feedback, synchronize project artifacts, accelerate CI/CD, and enforce best practices with ease. Bring security, speed, and reliability to your workflows—directly within your Sim-powered automation environment, with full integration into your organizations GitHub strategy.
{/* MANUAL-CONTENT-END */}
## Usage Instructions ## Usage Instructions
Integrate Github into the workflow. Can get get PR details, create PR comment, get repository info, and get latest commit. Can be used in trigger mode to trigger a workflow when a PR is created, commented on, or a commit is pushed. Integrate Github into the workflow. Can get get PR details, create PR comment, get repository info, and get latest commit. Can be used in trigger mode to trigger a workflow when a PR is created, commented on, or a commit is pushed.

View File

@@ -11,17 +11,55 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
/> />
{/* MANUAL-CONTENT-START:intro */} {/* MANUAL-CONTENT-START:intro */}
[Google Docs](https://docs.google.com) is Googles collaborative, cloud-based document service, enabling users to create, edit, and share documents in real time. As an integral part of Google Workspace, Docs offers rich formatting tools, commenting, version history, and seamless integration with other Google productivity tools. [Google Docs](https://docs.google.com) is a powerful cloud-based document creation and editing service that allows users to create, edit, and collaborate on documents in real-time. As part of Google's productivity suite, Google Docs offers a versatile platform for text documents with robust formatting, commenting, and sharing capabilities.
Google Docs empowers individuals and teams to: Learn how to integrate the Google Docs "Read" tool in Sim to effortlessly fetch data from your docs and to integrate into your workflows. This tutorial walks you through connecting Google Docs, setting up data reads, and using that information to automate processes in real-time. Perfect for syncing live data with your agents.
- **Create and format documents:** Develop rich text documents with advanced formatting, images, and tables. <iframe
- **Collaborate and comment:** Multiple users can edit and comment with suggestions instantly. width="100%"
- **Track changes and version history:** Review, revert, and manage revisions over time. height="400"
- **Access from any device:** Work on documents from web, mobile, or desktop with full cloud synchronization. src="https://www.youtube.com/embed/f41gy9rBHhE"
- **Integrate across Google services:** Connect Docs with Drive, Sheets, Slides, and external platforms for powerful workflows. title="Use the Google Docs Read tool in Sim"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
In Sim, the Google Docs integration allows your agents to read document content, write new content, and create documents programmatically as part of automated workflows. This integration unlocks automation such as document generation, report writing, content extraction, and collaborative editing—bridging the gap between AI-driven workflows and document management in your organization. Learn how to integrate the Google Docs "Update" tool in Sim to effortlessly add content in your docs through your workflows. This tutorial walks you through connecting Google Docs, configuring data writes, and using that information to automate document updates seamlessly. Perfect for maintaining dynamic, real-time documentation with minimal effort.
<iframe
width="100%"
height="400"
src="https://www.youtube.com/embed/L64ROHS2ivA"
title="Use the Google Docs Update tool in Sim"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
Learn how to integrate the Google Docs "Create" tool in Sim to effortlessly generate new documents through your workflows. This tutorial walks you through connecting Google Docs, setting up document creation, and using workflow data to populate content automatically. Perfect for streamlining document generation and enhancing productivity.
<iframe
width="100%"
height="400"
src="https://www.youtube.com/embed/lWpHH4qddWk"
title="Use the Google Docs Create tool in Sim"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
With Google Docs, you can:
- **Create and edit documents**: Develop text documents with comprehensive formatting options
- **Collaborate in real-time**: Work simultaneously with multiple users on the same document
- **Track changes**: View revision history and restore previous versions
- **Comment and suggest**: Provide feedback and propose edits without changing the original content
- **Access anywhere**: Use Google Docs across devices with automatic cloud synchronization
- **Work offline**: Continue working without internet connection with changes syncing when back online
- **Integrate with other services**: Connect with Google Drive, Sheets, Slides, and third-party applications
In Sim, the Google Docs integration enables your agents to interact directly with document content programmatically. This allows for powerful automation scenarios such as document creation, content extraction, collaborative editing, and document management. Your agents can read existing documents to extract information, write to documents to update content, and create new documents from scratch. This integration bridges the gap between your AI workflows and document management, enabling seamless interaction with one of the world's most widely used document platforms. By connecting Sim with Google Docs, you can automate document workflows, generate reports, extract insights from documents, and maintain documentation - all through your intelligent agents.
{/* MANUAL-CONTENT-END */} {/* MANUAL-CONTENT-END */}

View File

@@ -11,18 +11,30 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
/> />
{/* MANUAL-CONTENT-START:intro */} {/* MANUAL-CONTENT-START:intro */}
[Google Drive](https://drive.google.com) is Googles cloud-based file storage and synchronization service, making it easy to store, manage, share, and access files securely across devices and platforms. As a core element of Google Workspace, Google Drive offers robust tools for file organization, collaboration, and seamless integration with the broader productivity suite. [Google Drive](https://drive.google.com) is Google's cloud storage and file synchronization service that allows users to store files, synchronize files across devices, and share files with others. As a core component of Google's productivity ecosystem, Google Drive offers robust storage, organization, and collaboration capabilities.
Google Drive enables individuals and teams to: Learn how to integrate the Google Drive tool in Sim to effortlessly pull information from your Drive through your workflows. This tutorial walks you through connecting Google Drive, setting up data retrieval, and using stored documents and files to enhance automation. Perfect for syncing important data with your agents in real-time.
- **Store files in the cloud:** Access documents, images, videos, and more from anywhere with internet connectivity. <iframe
- **Organize and manage content:** Create and arrange folders, use naming conventions, and leverage search for fast retrieval. width="100%"
- **Share and collaborate:** Control file and folder permissions, share with individuals or groups, and collaborate in real time. height="400"
- **Leverage powerful search:** Quickly locate files using Googles search technology. src="https://www.youtube.com/embed/cRoRr4b-EAs"
- **Access across devices:** Work with your files on desktop, mobile, or web with full synchronization. title="Use the Google Drive tool in Sim"
- **Integrate deeply across Google services:** Connect with Google Docs, Sheets, Slides, and partner applications in your workflows. frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
In Sim, the Google Drive integration allows your agents to read, upload, download, list, and organize your Drive files programmatically. Agents can automate file management, streamline content workflows, and enable no-code automation around document storage and retrieval. By connecting Sim with Google Drive, you empower your agents to incorporate cloud file operations directly into intelligent business processes. With Google Drive, you can:
- **Store files in the cloud**: Upload and access your files from anywhere with internet access
- **Organize content**: Create folders, use color coding, and implement naming conventions
- **Share and collaborate**: Control access permissions and work simultaneously on files
- **Search efficiently**: Find files quickly with Google's powerful search technology
- **Access across devices**: Use Google Drive on desktop, mobile, and web platforms
- **Integrate with other services**: Connect with Google Docs, Sheets, Slides, and third-party applications
In Sim, the Google Drive integration enables your agents to interact directly with your cloud storage programmatically. This allows for powerful automation scenarios such as file management, content organization, and document workflows. Your agents can upload new files to specific folders, download existing files to process their contents, and list folder contents to navigate your storage structure. This integration bridges the gap between your AI workflows and your document management system, enabling seamless file operations without manual intervention. By connecting Sim with Google Drive, you can automate file-based workflows, manage documents intelligently, and incorporate cloud storage operations into your agent's capabilities.
{/* MANUAL-CONTENT-END */} {/* MANUAL-CONTENT-END */}

View File

@@ -11,9 +11,29 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
/> />
{/* MANUAL-CONTENT-START:intro */} {/* MANUAL-CONTENT-START:intro */}
[Google Search](https://www.google.com) is the world's most widely used web search engine, making it easy to find information, discover new content, and answer questions in real time. With advanced search algorithms, Google Search helps you quickly locate web pages, images, news, and more using simple or complex queries. [Google Search](https://www.google.com) is the world's most widely used search engine, providing access to billions of web pages and information sources. Google Search uses sophisticated algorithms to deliver relevant search results based on user queries, making it an essential tool for finding information on the internet.
In Sim, the Google Search integration allows your agents to search the web and retrieve live information as part of automated workflows. This enables powerful use cases such as automated research, fact-checking, knowledge synthesis, and dynamic content discovery. By connecting Sim with Google Search, your agents can perform queries, process and analyze web results, and incorporate the latest information into their decisions—without manual effort. Enhance your workflows with always up-to-date knowledge from across the internet. Learn how to integrate the Google Search tool in Sim to effortlessly fetch real-time search results through your workflows. This tutorial walks you through connecting Google Search, configuring search queries, and using live data to enhance automation. Perfect for powering your agents with up-to-date information and smarter decision-making.
<iframe
width="100%"
height="400"
src="https://www.youtube.com/embed/1B7hV9b5UMQ"
title="Use the Google Search tool in Sim"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
With Google Search, you can:
- **Find relevant information**: Access billions of web pages with Google's powerful search algorithms
- **Get specific results**: Use search operators to refine and target your queries
- **Discover diverse content**: Find text, images, videos, news, and other content types
- **Access knowledge graphs**: Get structured information about people, places, and things
- **Utilize search features**: Take advantage of specialized search tools like calculators, unit converters, and more
In Sim, the Google Search integration enables your agents to search the web programmatically and incorporate search results into their workflows. This allows for powerful automation scenarios such as research, fact-checking, data gathering, and information synthesis. Your agents can formulate search queries, retrieve relevant results, and extract information from those results to make decisions or generate insights. This integration bridges the gap between your AI workflows and the vast information available on the web, enabling your agents to access up-to-date information from across the internet. By connecting Sim with Google Search, you can create agents that stay informed with the latest information, verify facts, conduct research, and provide users with relevant web content - all without leaving your workflow.
{/* MANUAL-CONTENT-END */} {/* MANUAL-CONTENT-END */}

View File

@@ -10,20 +10,6 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#F64F9E" color="#F64F9E"
/> />
{/* MANUAL-CONTENT-START:intro */}
The Memory tool enables your agents to store, retrieve, and manage conversation memories across workflows. It acts as a persistent memory store that agents can access to maintain conversation context, recall facts, or track actions over time.
With the Memory tool, you can:
- **Add new memories**: Store relevant information, events, or conversation history by saving agent or user messages into a structured memory database
- **Retrieve memories**: Fetch specific memories or all memories tied to a conversation, helping agents recall previous interactions or facts
- **Delete memories**: Remove outdated or incorrect memories from the database to maintain accurate context
- **Append to existing conversations**: Update or expand on existing memory threads by appending new messages with the same conversation identifier
Sims Memory block is especially useful for building agents that require persistent state—helping them remember what was said earlier in a conversation, persist facts between tasks, or apply long-term history in decision-making. By integrating Memory, you enable richer, more contextual, and more dynamic workflows for your agents.
{/* MANUAL-CONTENT-END */}
## Usage Instructions ## Usage Instructions
Integrate Memory into the workflow. Can add, get a memory, get all memories, and delete memories. Integrate Memory into the workflow. Can add, get a memory, get all memories, and delete memories.

View File

@@ -24,7 +24,6 @@
"dynamodb", "dynamodb",
"elasticsearch", "elasticsearch",
"elevenlabs", "elevenlabs",
"enrich",
"exa", "exa",
"file", "file",
"firecrawl", "firecrawl",

View File

@@ -10,21 +10,6 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#181C1E" color="#181C1E"
/> />
{/* MANUAL-CONTENT-START:intro */}
The Notion tool integration enables your agents to read, create, and manage Notion pages and databases directly within your workflows. This allows you to automate the retrieval and updating of structured content, notes, documents, and more from your Notion workspace.
With the Notion tool, you can:
- **Read pages or databases**: Extract rich content or metadata from specified Notion pages or entire databases
- **Create new content**: Programmatically create new pages or databases for dynamic content generation
- **Append content**: Add new blocks or properties to existing pages and databases
- **Query databases**: Run advanced filters and searches on structured Notion data for custom workflows
- **Search your workspace**: Locate pages and databases across your Notion workspace automatically
This tool is ideal for scenarios where agents need to synchronize information, generate reports, or maintain structured notes within Notion. By bringing Notion's capabilities into automated workflows, you empower your agents to interface with knowledge, documentation, and project management data programmatically and seamlessly.
{/* MANUAL-CONTENT-END */}
## Usage Instructions ## Usage Instructions
Integrate with Notion into the workflow. Can read page, read database, create page, create database, append content, query database, and search workspace. Integrate with Notion into the workflow. Can read page, read database, create page, create database, append content, query database, and search workspace.

View File

@@ -11,7 +11,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
/> />
{/* MANUAL-CONTENT-START:intro */} {/* MANUAL-CONTENT-START:intro */}
The [Pulse](https://www.pulseapi.com/) tool enables seamless extraction of text and structured content from a wide variety of documents—including PDFs, images, and Office files—using state-of-the-art OCR (Optical Character Recognition) powered by Pulse. Designed for automated agentic workflows, Pulse Parser makes it easy to unlock valuable information trapped in unstructured documents and integrate the extracted content directly into your workflow. The [Pulse](https://www.runpulse.com) tool enables seamless extraction of text and structured content from a wide variety of documents—including PDFs, images, and Office files—using state-of-the-art OCR (Optical Character Recognition) powered by Pulse. Designed for automated agentic workflows, Pulse Parser makes it easy to unlock valuable information trapped in unstructured documents and integrate the extracted content directly into your workflow.
With Pulse, you can: With Pulse, you can:

View File

@@ -13,6 +13,16 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
{/* MANUAL-CONTENT-START:intro */} {/* MANUAL-CONTENT-START:intro */}
[Slack](https://www.slack.com/) is a business communication platform that offers teams a unified place for messaging, tools, and files. [Slack](https://www.slack.com/) is a business communication platform that offers teams a unified place for messaging, tools, and files.
<iframe
width="100%"
height="400"
src="https://www.youtube.com/embed/J5jz3UaWmE8"
title="Slack Integration with Sim"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
With Slack, you can: With Slack, you can:
- **Automate agent notifications**: Send real-time updates from your Sim agents to any Slack channel - **Automate agent notifications**: Send real-time updates from your Sim agents to any Slack channel

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -1,5 +1,5 @@
import { memo, useCallback } from 'react' import { memo, useCallback } from 'react'
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react' import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Lock, LogOut, Unlock } from 'lucide-react'
import { Button, Copy, PlayOutline, Tooltip, Trash2 } from '@/components/emcn' import { Button, Copy, PlayOutline, Tooltip, Trash2 } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
@@ -49,6 +49,7 @@ export const ActionBar = memo(
collaborativeBatchRemoveBlocks, collaborativeBatchRemoveBlocks,
collaborativeBatchToggleBlockEnabled, collaborativeBatchToggleBlockEnabled,
collaborativeBatchToggleBlockHandles, collaborativeBatchToggleBlockHandles,
collaborativeBatchToggleLocked,
} = useCollaborativeWorkflow() } = useCollaborativeWorkflow()
const { setPendingSelection } = useWorkflowRegistry() const { setPendingSelection } = useWorkflowRegistry()
const { handleRunFromBlock } = useWorkflowExecution() const { handleRunFromBlock } = useWorkflowExecution()
@@ -84,16 +85,28 @@ export const ActionBar = memo(
) )
}, [blockId, addNotification, collaborativeBatchAddBlocks, setPendingSelection]) }, [blockId, addNotification, collaborativeBatchAddBlocks, setPendingSelection])
const { isEnabled, horizontalHandles, parentId, parentType } = useWorkflowStore( const {
isEnabled,
horizontalHandles,
parentId,
parentType,
isLocked,
isParentLocked,
isParentDisabled,
} = useWorkflowStore(
useCallback( useCallback(
(state) => { (state) => {
const block = state.blocks[blockId] const block = state.blocks[blockId]
const parentId = block?.data?.parentId const parentId = block?.data?.parentId
const parentBlock = parentId ? state.blocks[parentId] : undefined
return { return {
isEnabled: block?.enabled ?? true, isEnabled: block?.enabled ?? true,
horizontalHandles: block?.horizontalHandles ?? false, horizontalHandles: block?.horizontalHandles ?? false,
parentId, parentId,
parentType: parentId ? state.blocks[parentId]?.type : undefined, parentType: parentBlock?.type,
isLocked: block?.locked ?? false,
isParentLocked: parentBlock?.locked ?? false,
isParentDisabled: parentBlock ? !parentBlock.enabled : false,
} }
}, },
[blockId] [blockId]
@@ -161,25 +174,27 @@ export const ActionBar = memo(
{!isNoteBlock && !isInsideSubflow && ( {!isNoteBlock && !isInsideSubflow && (
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<Button <span className='inline-flex'>
variant='ghost' <Button
onClick={(e) => { variant='ghost'
e.stopPropagation() onClick={(e) => {
if (canRunFromBlock && !disabled) { e.stopPropagation()
handleRunFromBlockClick() if (canRunFromBlock && !disabled) {
} handleRunFromBlockClick()
}} }
className={ACTION_BUTTON_STYLES} }}
disabled={disabled || !canRunFromBlock} className={ACTION_BUTTON_STYLES}
> disabled={disabled || !canRunFromBlock}
<PlayOutline className={ICON_SIZE} /> >
</Button> <PlayOutline className={ICON_SIZE} />
</Button>
</span>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content side='top'> <Tooltip.Content side='top'>
{(() => { {(() => {
if (disabled) return getTooltipMessage('Run from block') if (disabled) return getTooltipMessage('Run from block')
if (isExecuting) return 'Execution in progress' if (isExecuting) return 'Execution in progress'
if (!dependenciesSatisfied) return 'Run upstream blocks first' if (!dependenciesSatisfied) return 'Run previous blocks first'
return 'Run from block' return 'Run from block'
})()} })()}
</Tooltip.Content> </Tooltip.Content>
@@ -193,18 +208,54 @@ export const ActionBar = memo(
variant='ghost' variant='ghost'
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (!disabled) { // Can't enable if parent is disabled (must enable parent first)
const cantEnable = !isEnabled && isParentDisabled
if (!disabled && !isLocked && !isParentLocked && !cantEnable) {
collaborativeBatchToggleBlockEnabled([blockId]) collaborativeBatchToggleBlockEnabled([blockId])
} }
}} }}
className={ACTION_BUTTON_STYLES} className={ACTION_BUTTON_STYLES}
disabled={disabled} disabled={
disabled || isLocked || isParentLocked || (!isEnabled && isParentDisabled)
}
> >
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />} {isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />}
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content side='top'> <Tooltip.Content side='top'>
{getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')} {isLocked || isParentLocked
? 'Block is locked'
: !isEnabled && isParentDisabled
? 'Parent container is disabled'
: getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
</Tooltip.Content>
</Tooltip.Root>
)}
{userPermissions.canAdmin && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
// Can't unlock a block if its parent container is locked
if (!disabled && !(isLocked && isParentLocked)) {
collaborativeBatchToggleLocked([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled || (isLocked && isParentLocked)}
>
{isLocked ? <Unlock className={ICON_SIZE} /> : <Lock className={ICON_SIZE} />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{isLocked && isParentLocked
? 'Parent container is locked'
: isLocked
? 'Unlock Block'
: 'Lock Block'}
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
)} )}
@@ -237,12 +288,12 @@ export const ActionBar = memo(
variant='ghost' variant='ghost'
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (!disabled) { if (!disabled && !isLocked && !isParentLocked) {
collaborativeBatchToggleBlockHandles([blockId]) collaborativeBatchToggleBlockHandles([blockId])
} }
}} }}
className={ACTION_BUTTON_STYLES} className={ACTION_BUTTON_STYLES}
disabled={disabled} disabled={disabled || isLocked || isParentLocked}
> >
{horizontalHandles ? ( {horizontalHandles ? (
<ArrowLeftRight className={ICON_SIZE} /> <ArrowLeftRight className={ICON_SIZE} />
@@ -252,7 +303,9 @@ export const ActionBar = memo(
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content side='top'> <Tooltip.Content side='top'>
{getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')} {isLocked || isParentLocked
? 'Block is locked'
: getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
)} )}
@@ -264,19 +317,23 @@ export const ActionBar = memo(
variant='ghost' variant='ghost'
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (!disabled && userPermissions.canEdit) { if (!disabled && userPermissions.canEdit && !isLocked && !isParentLocked) {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('remove-from-subflow', { detail: { blockIds: [blockId] } }) new CustomEvent('remove-from-subflow', { detail: { blockIds: [blockId] } })
) )
} }
}} }}
className={ACTION_BUTTON_STYLES} className={ACTION_BUTTON_STYLES}
disabled={disabled || !userPermissions.canEdit} disabled={disabled || !userPermissions.canEdit || isLocked || isParentLocked}
> >
<LogOut className={ICON_SIZE} /> <LogOut className={ICON_SIZE} />
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content side='top'>{getTooltipMessage('Remove from Subflow')}</Tooltip.Content> <Tooltip.Content side='top'>
{isLocked || isParentLocked
? 'Block is locked'
: getTooltipMessage('Remove from Subflow')}
</Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
)} )}
@@ -286,17 +343,19 @@ export const ActionBar = memo(
variant='ghost' variant='ghost'
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (!disabled) { if (!disabled && !isLocked && !isParentLocked) {
collaborativeBatchRemoveBlocks([blockId]) collaborativeBatchRemoveBlocks([blockId])
} }
}} }}
className={ACTION_BUTTON_STYLES} className={ACTION_BUTTON_STYLES}
disabled={disabled} disabled={disabled || isLocked || isParentLocked}
> >
<Trash2 className={ICON_SIZE} /> <Trash2 className={ICON_SIZE} />
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content side='top'>{getTooltipMessage('Delete Block')}</Tooltip.Content> <Tooltip.Content side='top'>
{isLocked || isParentLocked ? 'Block is locked' : getTooltipMessage('Delete Block')}
</Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
</div> </div>
) )

View File

@@ -20,6 +20,9 @@ export interface BlockInfo {
horizontalHandles: boolean horizontalHandles: boolean
parentId?: string parentId?: string
parentType?: string parentType?: string
locked?: boolean
isParentLocked?: boolean
isParentDisabled?: boolean
} }
/** /**
@@ -46,10 +49,17 @@ export interface BlockMenuProps {
showRemoveFromSubflow?: boolean showRemoveFromSubflow?: boolean
/** Whether run from block is available (has snapshot, was executed, not inside subflow) */ /** Whether run from block is available (has snapshot, was executed, not inside subflow) */
canRunFromBlock?: boolean canRunFromBlock?: boolean
/** Whether to disable edit actions (user can't edit OR blocks are locked) */
disableEdit?: boolean disableEdit?: boolean
/** Whether the user has edit permission (ignoring locked state) */
userCanEdit?: boolean
isExecuting?: boolean isExecuting?: boolean
/** Whether the selected block is a trigger (has no incoming edges) */ /** Whether the selected block is a trigger (has no incoming edges) */
isPositionalTrigger?: boolean isPositionalTrigger?: boolean
/** Callback to toggle locked state of selected blocks */
onToggleLocked?: () => void
/** Whether the user has admin permissions */
canAdmin?: boolean
} }
/** /**
@@ -78,13 +88,22 @@ export function BlockMenu({
showRemoveFromSubflow = false, showRemoveFromSubflow = false,
canRunFromBlock = false, canRunFromBlock = false,
disableEdit = false, disableEdit = false,
userCanEdit = true,
isExecuting = false, isExecuting = false,
isPositionalTrigger = false, isPositionalTrigger = false,
onToggleLocked,
canAdmin = false,
}: BlockMenuProps) { }: BlockMenuProps) {
const isSingleBlock = selectedBlocks.length === 1 const isSingleBlock = selectedBlocks.length === 1
const allEnabled = selectedBlocks.every((b) => b.enabled) const allEnabled = selectedBlocks.every((b) => b.enabled)
const allDisabled = selectedBlocks.every((b) => !b.enabled) const allDisabled = selectedBlocks.every((b) => !b.enabled)
const allLocked = selectedBlocks.every((b) => b.locked)
const allUnlocked = selectedBlocks.every((b) => !b.locked)
// Can't unlock blocks that have locked parents
const hasBlockWithLockedParent = selectedBlocks.some((b) => b.locked && b.isParentLocked)
// Can't enable blocks that have disabled parents
const hasBlockWithDisabledParent = selectedBlocks.some((b) => !b.enabled && b.isParentDisabled)
const hasSingletonBlock = selectedBlocks.some( const hasSingletonBlock = selectedBlocks.some(
(b) => (b) =>
@@ -108,6 +127,12 @@ export function BlockMenu({
return 'Toggle Enabled' return 'Toggle Enabled'
} }
const getToggleLockedLabel = () => {
if (allLocked) return 'Unlock'
if (allUnlocked) return 'Lock'
return 'Toggle Lock'
}
return ( return (
<Popover <Popover
open={isOpen} open={isOpen}
@@ -139,7 +164,7 @@ export function BlockMenu({
</PopoverItem> </PopoverItem>
<PopoverItem <PopoverItem
className='group' className='group'
disabled={disableEdit || !hasClipboard} disabled={!userCanEdit || !hasClipboard}
onClick={() => { onClick={() => {
onPaste() onPaste()
onClose() onClose()
@@ -150,7 +175,7 @@ export function BlockMenu({
</PopoverItem> </PopoverItem>
{!hasSingletonBlock && ( {!hasSingletonBlock && (
<PopoverItem <PopoverItem
disabled={disableEdit} disabled={!userCanEdit}
onClick={() => { onClick={() => {
onDuplicate() onDuplicate()
onClose() onClose()
@@ -164,13 +189,15 @@ export function BlockMenu({
{!allNoteBlocks && <PopoverDivider />} {!allNoteBlocks && <PopoverDivider />}
{!allNoteBlocks && ( {!allNoteBlocks && (
<PopoverItem <PopoverItem
disabled={disableEdit} disabled={disableEdit || hasBlockWithDisabledParent}
onClick={() => { onClick={() => {
onToggleEnabled() if (!disableEdit && !hasBlockWithDisabledParent) {
onClose() onToggleEnabled()
onClose()
}
}} }}
> >
{getToggleEnabledLabel()} {hasBlockWithDisabledParent ? 'Parent is disabled' : getToggleEnabledLabel()}
</PopoverItem> </PopoverItem>
)} )}
{!allNoteBlocks && !isSubflow && ( {!allNoteBlocks && !isSubflow && (
@@ -195,6 +222,19 @@ export function BlockMenu({
Remove from Subflow Remove from Subflow
</PopoverItem> </PopoverItem>
)} )}
{canAdmin && onToggleLocked && (
<PopoverItem
disabled={hasBlockWithLockedParent}
onClick={() => {
if (!hasBlockWithLockedParent) {
onToggleLocked()
onClose()
}
}}
>
{hasBlockWithLockedParent ? 'Parent is locked' : getToggleLockedLabel()}
</PopoverItem>
)}
{/* Single block actions */} {/* Single block actions */}
{isSingleBlock && <PopoverDivider />} {isSingleBlock && <PopoverDivider />}

View File

@@ -34,6 +34,8 @@ export interface CanvasMenuProps {
canUndo?: boolean canUndo?: boolean
canRedo?: boolean canRedo?: boolean
isInvitationsDisabled?: boolean isInvitationsDisabled?: boolean
/** Whether the workflow has locked blocks (disables auto-layout) */
hasLockedBlocks?: boolean
} }
/** /**
@@ -60,6 +62,7 @@ export function CanvasMenu({
disableEdit = false, disableEdit = false,
canUndo = false, canUndo = false,
canRedo = false, canRedo = false,
hasLockedBlocks = false,
}: CanvasMenuProps) { }: CanvasMenuProps) {
return ( return (
<Popover <Popover
@@ -129,11 +132,12 @@ export function CanvasMenu({
</PopoverItem> </PopoverItem>
<PopoverItem <PopoverItem
className='group' className='group'
disabled={disableEdit} disabled={disableEdit || hasLockedBlocks}
onClick={() => { onClick={() => {
onAutoLayout() onAutoLayout()
onClose() onClose()
}} }}
title={hasLockedBlocks ? 'Unlock blocks to use auto-layout' : undefined}
> >
<span>Auto-layout</span> <span>Auto-layout</span>
<span className='ml-auto opacity-70 group-hover:opacity-100'>L</span> <span className='ml-auto opacity-70 group-hover:opacity-100'>L</span>

View File

@@ -9,7 +9,9 @@ import {
ChevronUp, ChevronUp,
ExternalLink, ExternalLink,
Loader2, Loader2,
Lock,
Pencil, Pencil,
Unlock,
} from 'lucide-react' } from 'lucide-react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
@@ -46,6 +48,7 @@ import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePanelEditorStore } from '@/stores/panel' import { usePanelEditorStore } from '@/stores/panel'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/** Stable empty object to avoid creating new references */ /** Stable empty object to avoid creating new references */
const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any> const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any>
@@ -110,6 +113,14 @@ export function Editor() {
// Get user permissions // Get user permissions
const userPermissions = useUserPermissionsContext() const userPermissions = useUserPermissionsContext()
// Check if block is locked (or inside a locked container) and compute edit permission
// Locked blocks cannot be edited by anyone (admins can only lock/unlock)
const blocks = useWorkflowStore((state) => state.blocks)
const parentId = currentBlock?.data?.parentId as string | undefined
const isParentLocked = parentId ? (blocks[parentId]?.locked ?? false) : false
const isLocked = (currentBlock?.locked ?? false) || isParentLocked
const canEditBlock = userPermissions.canEdit && !isLocked
// Get active workflow ID // Get active workflow ID
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
@@ -150,9 +161,7 @@ export function Editor() {
blockSubBlockValues, blockSubBlockValues,
canonicalIndex canonicalIndex
) )
const displayAdvancedOptions = userPermissions.canEdit const displayAdvancedOptions = canEditBlock ? advancedMode : advancedMode || advancedValuesPresent
? advancedMode
: advancedMode || advancedValuesPresent
const hasAdvancedOnlyFields = useMemo(() => { const hasAdvancedOnlyFields = useMemo(() => {
for (const subBlock of subBlocksForCanonical) { for (const subBlock of subBlocksForCanonical) {
@@ -219,13 +228,14 @@ export function Editor() {
collaborativeSetBlockCanonicalMode, collaborativeSetBlockCanonicalMode,
collaborativeUpdateBlockName, collaborativeUpdateBlockName,
collaborativeToggleBlockAdvancedMode, collaborativeToggleBlockAdvancedMode,
collaborativeBatchToggleLocked,
} = useCollaborativeWorkflow() } = useCollaborativeWorkflow()
// Advanced mode toggle handler // Advanced mode toggle handler
const handleToggleAdvancedMode = useCallback(() => { const handleToggleAdvancedMode = useCallback(() => {
if (!currentBlockId || !userPermissions.canEdit) return if (!currentBlockId || !canEditBlock) return
collaborativeToggleBlockAdvancedMode(currentBlockId) collaborativeToggleBlockAdvancedMode(currentBlockId)
}, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode]) }, [currentBlockId, canEditBlock, collaborativeToggleBlockAdvancedMode])
// Rename state // Rename state
const [isRenaming, setIsRenaming] = useState(false) const [isRenaming, setIsRenaming] = useState(false)
@@ -236,10 +246,10 @@ export function Editor() {
* Handles starting the rename process. * Handles starting the rename process.
*/ */
const handleStartRename = useCallback(() => { const handleStartRename = useCallback(() => {
if (!userPermissions.canEdit || !currentBlock) return if (!canEditBlock || !currentBlock) return
setEditedName(currentBlock.name || '') setEditedName(currentBlock.name || '')
setIsRenaming(true) setIsRenaming(true)
}, [userPermissions.canEdit, currentBlock]) }, [canEditBlock, currentBlock])
/** /**
* Handles saving the renamed block. * Handles saving the renamed block.
@@ -358,6 +368,36 @@ export function Editor() {
)} )}
</div> </div>
<div className='flex shrink-0 items-center gap-[8px]'> <div className='flex shrink-0 items-center gap-[8px]'>
{/* Locked indicator - clickable to unlock if user has admin permissions, block is locked, and parent is not locked */}
{isLocked && currentBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
{userPermissions.canAdmin && currentBlock.locked && !isParentLocked ? (
<Button
variant='ghost'
className='p-0'
onClick={() => collaborativeBatchToggleLocked([currentBlockId!])}
aria-label='Unlock block'
>
<Unlock className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
</Button>
) : (
<div className='flex items-center justify-center'>
<Lock className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
</div>
)}
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>
{isParentLocked
? 'Parent container is locked'
: userPermissions.canAdmin && currentBlock.locked
? 'Unlock block'
: 'Block is locked'}
</p>
</Tooltip.Content>
</Tooltip.Root>
)}
{/* Rename button */} {/* Rename button */}
{currentBlock && ( {currentBlock && (
<Tooltip.Root> <Tooltip.Root>
@@ -366,7 +406,7 @@ export function Editor() {
variant='ghost' variant='ghost'
className='p-0' className='p-0'
onClick={isRenaming ? handleSaveRename : handleStartRename} onClick={isRenaming ? handleSaveRename : handleStartRename}
disabled={!userPermissions.canEdit} disabled={!canEditBlock}
aria-label={isRenaming ? 'Save name' : 'Rename block'} aria-label={isRenaming ? 'Save name' : 'Rename block'}
> >
{isRenaming ? ( {isRenaming ? (
@@ -434,7 +474,7 @@ export function Editor() {
incomingConnections={incomingConnections} incomingConnections={incomingConnections}
handleConnectionsResizeMouseDown={handleConnectionsResizeMouseDown} handleConnectionsResizeMouseDown={handleConnectionsResizeMouseDown}
toggleConnectionsCollapsed={toggleConnectionsCollapsed} toggleConnectionsCollapsed={toggleConnectionsCollapsed}
userCanEdit={userPermissions.canEdit} userCanEdit={canEditBlock}
isConnectionsAtMinHeight={isConnectionsAtMinHeight} isConnectionsAtMinHeight={isConnectionsAtMinHeight}
/> />
) : ( ) : (
@@ -542,14 +582,14 @@ export function Editor() {
config={subBlock} config={subBlock}
isPreview={false} isPreview={false}
subBlockValues={subBlockState} subBlockValues={subBlockState}
disabled={!userPermissions.canEdit} disabled={!canEditBlock}
fieldDiffStatus={undefined} fieldDiffStatus={undefined}
allowExpandInPreview={false} allowExpandInPreview={false}
canonicalToggle={ canonicalToggle={
isCanonicalSwap && canonicalMode && canonicalId isCanonicalSwap && canonicalMode && canonicalId
? { ? {
mode: canonicalMode, mode: canonicalMode,
disabled: !userPermissions.canEdit, disabled: !canEditBlock,
onToggle: () => { onToggle: () => {
if (!currentBlockId) return if (!currentBlockId) return
const nextMode = const nextMode =
@@ -579,7 +619,7 @@ export function Editor() {
) )
})} })}
{hasAdvancedOnlyFields && userPermissions.canEdit && ( {hasAdvancedOnlyFields && canEditBlock && (
<div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'> <div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'>
<div <div
className='h-[1.25px] flex-1' className='h-[1.25px] flex-1'
@@ -624,7 +664,7 @@ export function Editor() {
config={subBlock} config={subBlock}
isPreview={false} isPreview={false}
subBlockValues={subBlockState} subBlockValues={subBlockState}
disabled={!userPermissions.canEdit} disabled={!canEditBlock}
fieldDiffStatus={undefined} fieldDiffStatus={undefined}
allowExpandInPreview={false} allowExpandInPreview={false}
/> />

View File

@@ -45,11 +45,13 @@ import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowI
import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks' import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
import { usePermissionConfig } from '@/hooks/use-permission-config' import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useChatStore } from '@/stores/chat/store' import { useChatStore } from '@/stores/chat/store'
import { useNotificationStore } from '@/stores/notifications/store'
import type { PanelTab } from '@/stores/panel' import type { PanelTab } from '@/stores/panel'
import { usePanelStore, useVariablesStore as usePanelVariablesStore } from '@/stores/panel' import { usePanelStore, useVariablesStore as usePanelVariablesStore } from '@/stores/panel'
import { useVariablesStore } from '@/stores/variables/store' import { useVariablesStore } from '@/stores/variables/store'
import { getWorkflowWithValues } from '@/stores/workflows' import { getWorkflowWithValues } from '@/stores/workflows'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('Panel') const logger = createLogger('Panel')
/** /**
@@ -119,6 +121,11 @@ export const Panel = memo(function Panel() {
hydration.phase === 'state-loading' hydration.phase === 'state-loading'
const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null) const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null)
// Check for locked blocks (disables auto-layout)
const hasLockedBlocks = useWorkflowStore((state) =>
Object.values(state.blocks).some((block) => block.locked)
)
// Delete workflow hook // Delete workflow hook
const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({ const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
workspaceId, workspaceId,
@@ -230,11 +237,24 @@ export const Panel = memo(function Panel() {
setIsAutoLayouting(true) setIsAutoLayouting(true)
try { try {
await autoLayoutWithFitView() const result = await autoLayoutWithFitView()
if (!result.success && result.error) {
useNotificationStore.getState().addNotification({
level: 'info',
message: result.error,
workflowId: activeWorkflowId || undefined,
})
}
} finally { } finally {
setIsAutoLayouting(false) setIsAutoLayouting(false)
} }
}, [isExecuting, userPermissions.canEdit, isAutoLayouting, autoLayoutWithFitView]) }, [
isExecuting,
userPermissions.canEdit,
isAutoLayouting,
autoLayoutWithFitView,
activeWorkflowId,
])
/** /**
* Handles exporting workflow as JSON * Handles exporting workflow as JSON
@@ -404,7 +424,10 @@ export const Panel = memo(function Panel() {
<PopoverContent align='start' side='bottom' sideOffset={8}> <PopoverContent align='start' side='bottom' sideOffset={8}>
<PopoverItem <PopoverItem
onClick={handleAutoLayout} onClick={handleAutoLayout}
disabled={isExecuting || !userPermissions.canEdit || isAutoLayouting} disabled={
isExecuting || !userPermissions.canEdit || isAutoLayouting || hasLockedBlocks
}
title={hasLockedBlocks ? 'Unlock blocks to use auto-layout' : undefined}
> >
<Layout className='h-3 w-3' animate={isAutoLayouting} variant='clockwise' /> <Layout className='h-3 w-3' animate={isAutoLayouting} variant='clockwise' />
<span>Auto layout</span> <span>Auto layout</span>

View File

@@ -80,6 +80,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
: undefined : undefined
const isEnabled = currentBlock?.enabled ?? true const isEnabled = currentBlock?.enabled ?? true
const isLocked = currentBlock?.locked ?? false
const isPreview = data?.isPreview || false const isPreview = data?.isPreview || false
// Focus state // Focus state
@@ -200,7 +201,10 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
{blockName} {blockName}
</span> </span>
</div> </div>
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>} <div className='flex items-center gap-1'>
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
</div>
</div> </div>
{!isPreview && ( {!isPreview && (

View File

@@ -18,6 +18,8 @@ export interface UseBlockStateReturn {
diffStatus: DiffStatus diffStatus: DiffStatus
/** Whether this is a deleted block in diff mode */ /** Whether this is a deleted block in diff mode */
isDeletedBlock: boolean isDeletedBlock: boolean
/** Whether the block is locked */
isLocked: boolean
} }
/** /**
@@ -40,6 +42,11 @@ export function useBlockState(
? (data.blockState?.enabled ?? true) ? (data.blockState?.enabled ?? true)
: (currentBlock?.enabled ?? true) : (currentBlock?.enabled ?? true)
// Determine if block is locked
const isLocked = data.isPreview
? (data.blockState?.locked ?? false)
: (currentBlock?.locked ?? false)
// Get diff status // Get diff status
const diffStatus: DiffStatus = const diffStatus: DiffStatus =
currentWorkflow.isDiffMode && currentBlock && hasDiffStatus(currentBlock) currentWorkflow.isDiffMode && currentBlock && hasDiffStatus(currentBlock)
@@ -68,5 +75,6 @@ export function useBlockState(
isActive, isActive,
diffStatus, diffStatus,
isDeletedBlock: isDeletedBlock ?? false, isDeletedBlock: isDeletedBlock ?? false,
isLocked,
} }
} }

View File

@@ -672,6 +672,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
currentWorkflow, currentWorkflow,
activeWorkflowId, activeWorkflowId,
isEnabled, isEnabled,
isLocked,
handleClick, handleClick,
hasRing, hasRing,
ringStyles, ringStyles,
@@ -1100,7 +1101,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
{name} {name}
</span> </span>
</div> </div>
<div className='relative z-10 flex flex-shrink-0 items-center gap-2'> <div className='relative z-10 flex flex-shrink-0 items-center gap-1'>
{isWorkflowSelector && {isWorkflowSelector &&
childWorkflowId && childWorkflowId &&
typeof childIsDeployed === 'boolean' && typeof childIsDeployed === 'boolean' &&
@@ -1133,6 +1134,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
</Tooltip.Root> </Tooltip.Root>
)} )}
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>} {!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
{type === 'schedule' && shouldShowScheduleBadge && scheduleInfo?.isDisabled && ( {type === 'schedule' && shouldShowScheduleBadge && scheduleInfo?.isDisabled && (
<Tooltip.Root> <Tooltip.Root>

View File

@@ -47,6 +47,7 @@ export function useBlockVisual({
isActive: isExecuting, isActive: isExecuting,
diffStatus, diffStatus,
isDeletedBlock, isDeletedBlock,
isLocked,
} = useBlockState(blockId, currentWorkflow, data) } = useBlockState(blockId, currentWorkflow, data)
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId) const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
@@ -103,6 +104,7 @@ export function useBlockVisual({
currentWorkflow, currentWorkflow,
activeWorkflowId, activeWorkflowId,
isEnabled, isEnabled,
isLocked,
handleClick, handleClick,
hasRing, hasRing,
ringStyles, ringStyles,

View File

@@ -31,7 +31,8 @@ export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasCo
nodes.map((n) => { nodes.map((n) => {
const block = blocks[n.id] const block = blocks[n.id]
const parentId = block?.data?.parentId const parentId = block?.data?.parentId
const parentType = parentId ? blocks[parentId]?.type : undefined const parentBlock = parentId ? blocks[parentId] : undefined
const parentType = parentBlock?.type
return { return {
id: n.id, id: n.id,
type: block?.type || '', type: block?.type || '',
@@ -39,6 +40,9 @@ export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasCo
horizontalHandles: block?.horizontalHandles ?? false, horizontalHandles: block?.horizontalHandles ?? false,
parentId, parentId,
parentType, parentType,
locked: block?.locked ?? false,
isParentLocked: parentBlock?.locked ?? false,
isParentDisabled: parentBlock ? !parentBlock.enabled : false,
} }
}), }),
[blocks] [blocks]

View File

@@ -52,6 +52,16 @@ export async function applyAutoLayoutAndUpdateStore(
return { success: false, error: 'No blocks to layout' } return { success: false, error: 'No blocks to layout' }
} }
// Check for locked blocks - auto-layout is disabled when blocks are locked
const hasLockedBlocks = Object.values(blocks).some((block) => block.locked)
if (hasLockedBlocks) {
logger.info('Auto layout skipped: workflow contains locked blocks', { workflowId })
return {
success: false,
error: 'Auto-layout is disabled when blocks are locked. Unlock blocks to use auto-layout.',
}
}
// Merge with default options // Merge with default options
const layoutOptions = { const layoutOptions = {
spacing: { spacing: {

View File

@@ -0,0 +1,87 @@
import type { BlockState } from '@/stores/workflows/workflow/types'
/**
* Result of filtering protected blocks from a deletion operation
*/
export interface FilterProtectedBlocksResult {
/** Block IDs that can be deleted (not protected) */
deletableIds: string[]
/** Block IDs that are protected and cannot be deleted */
protectedIds: string[]
/** Whether all blocks are protected (deletion should be cancelled entirely) */
allProtected: boolean
}
/**
* Checks if a block is protected from editing/deletion.
* A block is protected if it is locked or if its parent container is locked.
*
* @param blockId - The ID of the block to check
* @param blocks - Record of all blocks in the workflow
* @returns True if the block is protected
*/
export function isBlockProtected(blockId: string, blocks: Record<string, BlockState>): boolean {
const block = blocks[blockId]
if (!block) return false
// Block is locked directly
if (block.locked) return true
// Block is inside a locked container
const parentId = block.data?.parentId
if (parentId && blocks[parentId]?.locked) return true
return false
}
/**
* Checks if an edge is protected from modification.
* An edge is protected if either its source or target block is protected.
*
* @param edge - The edge to check (must have source and target)
* @param blocks - Record of all blocks in the workflow
* @returns True if the edge is protected
*/
export function isEdgeProtected(
edge: { source: string; target: string },
blocks: Record<string, BlockState>
): boolean {
return isBlockProtected(edge.source, blocks) || isBlockProtected(edge.target, blocks)
}
/**
* Filters out protected blocks from a list of block IDs for deletion.
* Protected blocks are those that are locked or inside a locked container.
*
* @param blockIds - Array of block IDs to filter
* @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<string, BlockState>
): 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,
}
}
/**
* Checks if any blocks in the selection are protected.
* Useful for determining if edit actions should be disabled.
*
* @param blockIds - Array of block IDs to check
* @param blocks - Record of all blocks in the workflow
* @returns True if any block is protected
*/
export function hasProtectedBlocks(
blockIds: string[],
blocks: Record<string, BlockState>
): boolean {
return blockIds.some((id) => isBlockProtected(id, blocks))
}

View File

@@ -1,4 +1,5 @@
export * from './auto-layout-utils' export * from './auto-layout-utils'
export * from './block-protection-utils'
export * from './block-ring-utils' export * from './block-ring-utils'
export * from './node-position-utils' export * from './node-position-utils'
export * from './workflow-canvas-helpers' export * from './workflow-canvas-helpers'

View File

@@ -55,7 +55,10 @@ import {
clearDragHighlights, clearDragHighlights,
computeClampedPositionUpdates, computeClampedPositionUpdates,
estimateBlockDimensions, estimateBlockDimensions,
filterProtectedBlocks,
getClampedPositionForNode, getClampedPositionForNode,
isBlockProtected,
isEdgeProtected,
isInEditableElement, isInEditableElement,
resolveParentChildSelectionConflicts, resolveParentChildSelectionConflicts,
validateTriggerPaste, validateTriggerPaste,
@@ -543,6 +546,7 @@ const WorkflowContent = React.memo(() => {
collaborativeBatchRemoveBlocks, collaborativeBatchRemoveBlocks,
collaborativeBatchToggleBlockEnabled, collaborativeBatchToggleBlockEnabled,
collaborativeBatchToggleBlockHandles, collaborativeBatchToggleBlockHandles,
collaborativeBatchToggleLocked,
undo, undo,
redo, redo,
} = useCollaborativeWorkflow() } = useCollaborativeWorkflow()
@@ -1069,8 +1073,27 @@ const WorkflowContent = React.memo(() => {
const handleContextDelete = useCallback(() => { const handleContextDelete = useCallback(() => {
const blockIds = contextMenuBlocks.map((b) => b.id) const blockIds = contextMenuBlocks.map((b) => b.id)
collaborativeBatchRemoveBlocks(blockIds) const { deletableIds, protectedIds, allProtected } = filterProtectedBlocks(blockIds, blocks)
}, [contextMenuBlocks, collaborativeBatchRemoveBlocks])
if (protectedIds.length > 0) {
if (allProtected) {
addNotification({
level: 'info',
message: 'Cannot delete locked blocks or blocks inside locked containers',
workflowId: activeWorkflowId || undefined,
})
return
}
addNotification({
level: 'info',
message: `Skipped ${protectedIds.length} protected block(s)`,
workflowId: activeWorkflowId || undefined,
})
}
if (deletableIds.length > 0) {
collaborativeBatchRemoveBlocks(deletableIds)
}
}, [contextMenuBlocks, collaborativeBatchRemoveBlocks, addNotification, activeWorkflowId, blocks])
const handleContextToggleEnabled = useCallback(() => { const handleContextToggleEnabled = useCallback(() => {
const blockIds = contextMenuBlocks.map((block) => block.id) const blockIds = contextMenuBlocks.map((block) => block.id)
@@ -1082,6 +1105,11 @@ const WorkflowContent = React.memo(() => {
collaborativeBatchToggleBlockHandles(blockIds) collaborativeBatchToggleBlockHandles(blockIds)
}, [contextMenuBlocks, collaborativeBatchToggleBlockHandles]) }, [contextMenuBlocks, collaborativeBatchToggleBlockHandles])
const handleContextToggleLocked = useCallback(() => {
const blockIds = contextMenuBlocks.map((block) => block.id)
collaborativeBatchToggleLocked(blockIds)
}, [contextMenuBlocks, collaborativeBatchToggleLocked])
const handleContextRemoveFromSubflow = useCallback(() => { const handleContextRemoveFromSubflow = useCallback(() => {
const blocksToRemove = contextMenuBlocks.filter( const blocksToRemove = contextMenuBlocks.filter(
(block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel') (block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
@@ -1951,7 +1979,6 @@ const WorkflowContent = React.memo(() => {
const loadingWorkflowRef = useRef<string | null>(null) const loadingWorkflowRef = useRef<string | null>(null)
const currentWorkflowExists = Boolean(workflows[workflowIdParam]) const currentWorkflowExists = Boolean(workflows[workflowIdParam])
/** Initializes workflow when it exists in registry and needs hydration. */
useEffect(() => { useEffect(() => {
const currentId = workflowIdParam const currentId = workflowIdParam
const currentWorkspaceHydration = hydration.workspaceId const currentWorkspaceHydration = hydration.workspaceId
@@ -2128,6 +2155,7 @@ const WorkflowContent = React.memo(() => {
parentId: block.data?.parentId, parentId: block.data?.parentId,
extent: block.data?.extent || undefined, extent: block.data?.extent || undefined,
dragHandle: '.workflow-drag-handle', dragHandle: '.workflow-drag-handle',
draggable: !isBlockProtected(block.id, blocks),
data: { data: {
...block.data, ...block.data,
name: block.name, name: block.name,
@@ -2163,6 +2191,7 @@ const WorkflowContent = React.memo(() => {
position, position,
parentId: block.data?.parentId, parentId: block.data?.parentId,
dragHandle, dragHandle,
draggable: !isBlockProtected(block.id, blocks),
extent: (() => { extent: (() => {
// Clamp children to subflow body (exclude header) // Clamp children to subflow body (exclude header)
const parentId = block.data?.parentId as string | undefined const parentId = block.data?.parentId as string | undefined
@@ -2491,12 +2520,18 @@ const WorkflowContent = React.memo(() => {
const edgeIdsToRemove = changes const edgeIdsToRemove = changes
.filter((change: any) => change.type === 'remove') .filter((change: any) => change.type === 'remove')
.map((change: any) => change.id) .map((change: any) => change.id)
.filter((edgeId: string) => {
// Prevent removing edges connected to protected blocks
const edge = edges.find((e) => e.id === edgeId)
if (!edge) return true
return !isEdgeProtected(edge, blocks)
})
if (edgeIdsToRemove.length > 0) { if (edgeIdsToRemove.length > 0) {
collaborativeBatchRemoveEdges(edgeIdsToRemove) collaborativeBatchRemoveEdges(edgeIdsToRemove)
} }
}, },
[collaborativeBatchRemoveEdges] [collaborativeBatchRemoveEdges, edges, blocks]
) )
/** /**
@@ -2558,6 +2593,16 @@ const WorkflowContent = React.memo(() => {
if (!sourceNode || !targetNode) return if (!sourceNode || !targetNode) return
// Prevent connections to/from protected blocks
if (isEdgeProtected(connection, blocks)) {
addNotification({
level: 'info',
message: 'Cannot connect to locked blocks or blocks inside locked containers',
workflowId: activeWorkflowId || undefined,
})
return
}
// Get parent information (handle container start node case) // Get parent information (handle container start node case)
const sourceParentId = const sourceParentId =
blocks[sourceNode.id]?.data?.parentId || blocks[sourceNode.id]?.data?.parentId ||
@@ -2620,7 +2665,7 @@ const WorkflowContent = React.memo(() => {
connectionCompletedRef.current = true connectionCompletedRef.current = true
} }
}, },
[addEdge, getNodes, blocks] [addEdge, getNodes, blocks, addNotification, activeWorkflowId]
) )
/** /**
@@ -2715,6 +2760,9 @@ const WorkflowContent = React.memo(() => {
// Only consider container nodes that aren't the dragged node // Only consider container nodes that aren't the dragged node
if (n.type !== 'subflowNode' || n.id === node.id) return false if (n.type !== 'subflowNode' || n.id === node.id) return false
// Don't allow dropping into locked containers
if (blocks[n.id]?.locked) return false
// Get the container's absolute position // Get the container's absolute position
const containerAbsolutePos = getNodeAbsolutePosition(n.id) const containerAbsolutePos = getNodeAbsolutePosition(n.id)
@@ -2807,6 +2855,11 @@ const WorkflowContent = React.memo(() => {
/** Captures initial parent ID and position when drag starts. */ /** Captures initial parent ID and position when drag starts. */
const onNodeDragStart = useCallback( const onNodeDragStart = useCallback(
(_event: React.MouseEvent, node: any) => { (_event: React.MouseEvent, node: any) => {
// Prevent dragging protected blocks
if (isBlockProtected(node.id, blocks)) {
return
}
// Store the original parent ID when starting to drag // Store the original parent ID when starting to drag
const currentParentId = blocks[node.id]?.data?.parentId || null const currentParentId = blocks[node.id]?.data?.parentId || null
setDragStartParentId(currentParentId) setDragStartParentId(currentParentId)
@@ -2835,7 +2888,7 @@ const WorkflowContent = React.memo(() => {
} }
}) })
}, },
[blocks, setDragStartPosition, getNodes, potentialParentId, setPotentialParentId] [blocks, setDragStartPosition, getNodes, setPotentialParentId]
) )
/** Handles node drag stop to establish parent-child relationships. */ /** Handles node drag stop to establish parent-child relationships. */
@@ -2897,6 +2950,18 @@ const WorkflowContent = React.memo(() => {
// Don't process parent changes if the node hasn't actually changed parent or is being moved within same parent // Don't process parent changes if the node hasn't actually changed parent or is being moved within same parent
if (potentialParentId === dragStartParentId) return if (potentialParentId === dragStartParentId) return
// Prevent moving locked blocks out of locked containers
// Unlocked blocks (e.g., duplicates) can be moved out freely
if (dragStartParentId && blocks[dragStartParentId]?.locked && blocks[node.id]?.locked) {
addNotification({
level: 'info',
message: 'Cannot move locked blocks out of locked containers',
workflowId: activeWorkflowId || undefined,
})
setPotentialParentId(dragStartParentId) // Reset to original parent
return
}
// Check if this is a starter block - starter blocks should never be in containers // Check if this is a starter block - starter blocks should never be in containers
const isStarterBlock = node.data?.type === 'starter' const isStarterBlock = node.data?.type === 'starter'
if (isStarterBlock) { if (isStarterBlock) {
@@ -3293,6 +3358,16 @@ const WorkflowContent = React.memo(() => {
/** Stable delete handler to avoid creating new function references per edge. */ /** Stable delete handler to avoid creating new function references per edge. */
const handleEdgeDelete = useCallback( const handleEdgeDelete = useCallback(
(edgeId: string) => { (edgeId: string) => {
// Prevent removing edges connected to protected blocks
const edge = edges.find((e) => e.id === edgeId)
if (edge && isEdgeProtected(edge, blocks)) {
addNotification({
level: 'info',
message: 'Cannot remove connections from locked blocks',
workflowId: activeWorkflowId || undefined,
})
return
}
removeEdge(edgeId) removeEdge(edgeId)
// Remove this edge from selection (find by edge ID value) // Remove this edge from selection (find by edge ID value)
setSelectedEdges((prev) => { setSelectedEdges((prev) => {
@@ -3305,7 +3380,7 @@ const WorkflowContent = React.memo(() => {
return next return next
}) })
}, },
[removeEdge] [removeEdge, edges, blocks, addNotification, activeWorkflowId]
) )
/** Transforms edges to include selection state and delete handlers. Memoized to prevent re-renders. */ /** Transforms edges to include selection state and delete handlers. Memoized to prevent re-renders. */
@@ -3346,9 +3421,15 @@ const WorkflowContent = React.memo(() => {
// Handle edge deletion first (edges take priority if selected) // Handle edge deletion first (edges take priority if selected)
if (selectedEdges.size > 0) { if (selectedEdges.size > 0) {
// Get all selected edge IDs and batch delete them // Get all selected edge IDs and filter out edges connected to protected blocks
const edgeIds = Array.from(selectedEdges.values()) const edgeIds = Array.from(selectedEdges.values()).filter((edgeId) => {
collaborativeBatchRemoveEdges(edgeIds) const edge = edges.find((e) => e.id === edgeId)
if (!edge) return true
return !isEdgeProtected(edge, blocks)
})
if (edgeIds.length > 0) {
collaborativeBatchRemoveEdges(edgeIds)
}
setSelectedEdges(new Map()) setSelectedEdges(new Map())
return return
} }
@@ -3365,7 +3446,29 @@ const WorkflowContent = React.memo(() => {
event.preventDefault() event.preventDefault()
const selectedIds = selectedNodes.map((node) => node.id) const selectedIds = selectedNodes.map((node) => node.id)
collaborativeBatchRemoveBlocks(selectedIds) const { deletableIds, protectedIds, allProtected } = filterProtectedBlocks(
selectedIds,
blocks
)
if (protectedIds.length > 0) {
if (allProtected) {
addNotification({
level: 'info',
message: 'Cannot delete locked blocks or blocks inside locked containers',
workflowId: activeWorkflowId || undefined,
})
return
}
addNotification({
level: 'info',
message: `Skipped ${protectedIds.length} protected block(s)`,
workflowId: activeWorkflowId || undefined,
})
}
if (deletableIds.length > 0) {
collaborativeBatchRemoveBlocks(deletableIds)
}
} }
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
@@ -3376,6 +3479,10 @@ const WorkflowContent = React.memo(() => {
getNodes, getNodes,
collaborativeBatchRemoveBlocks, collaborativeBatchRemoveBlocks,
effectivePermissions.canEdit, effectivePermissions.canEdit,
blocks,
edges,
addNotification,
activeWorkflowId,
]) ])
return ( return (
@@ -3496,12 +3603,18 @@ const WorkflowContent = React.memo(() => {
(b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel') (b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel')
)} )}
canRunFromBlock={runFromBlockState.canRun} canRunFromBlock={runFromBlockState.canRun}
disableEdit={!effectivePermissions.canEdit} disableEdit={
!effectivePermissions.canEdit ||
contextMenuBlocks.some((b) => b.locked || b.isParentLocked)
}
userCanEdit={effectivePermissions.canEdit}
isExecuting={isExecuting} isExecuting={isExecuting}
isPositionalTrigger={ isPositionalTrigger={
contextMenuBlocks.length === 1 && contextMenuBlocks.length === 1 &&
edges.filter((e) => e.target === contextMenuBlocks[0]?.id).length === 0 edges.filter((e) => e.target === contextMenuBlocks[0]?.id).length === 0
} }
onToggleLocked={handleContextToggleLocked}
canAdmin={effectivePermissions.canAdmin}
/> />
<CanvasMenu <CanvasMenu
@@ -3524,6 +3637,7 @@ const WorkflowContent = React.memo(() => {
disableEdit={!effectivePermissions.canEdit} disableEdit={!effectivePermissions.canEdit}
canUndo={canUndo} canUndo={canUndo}
canRedo={canRedo} canRedo={canRedo}
hasLockedBlocks={Object.values(blocks).some((b) => b.locked)}
/> />
</> </>
)} )}

View File

@@ -1,625 +0,0 @@
import { EnrichSoIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
export const EnrichBlock: BlockConfig = {
type: 'enrich',
name: 'Enrich',
description: 'B2B data enrichment and LinkedIn intelligence with Enrich.so',
authMode: AuthMode.ApiKey,
longDescription:
'Access real-time B2B data intelligence with Enrich.so. Enrich profiles from email addresses, find work emails from LinkedIn, verify email deliverability, search for people and companies, and analyze LinkedIn post engagement.',
docsLink: 'https://docs.enrich.so/',
category: 'tools',
bgColor: '#E5E5E6',
icon: EnrichSoIcon,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
// Person/Profile Enrichment
{ label: 'Email to Profile', id: 'email_to_profile' },
{ label: 'Email to Person (Lite)', id: 'email_to_person_lite' },
{ label: 'LinkedIn Profile Enrichment', id: 'linkedin_profile' },
// Email Finding
{ label: 'Find Email', id: 'find_email' },
{ label: 'LinkedIn to Work Email', id: 'linkedin_to_work_email' },
{ label: 'LinkedIn to Personal Email', id: 'linkedin_to_personal_email' },
// Phone Finding
{ label: 'Phone Finder (LinkedIn)', id: 'phone_finder' },
{ label: 'Email to Phone', id: 'email_to_phone' },
// Email Verification
{ label: 'Verify Email', id: 'verify_email' },
{ label: 'Disposable Email Check', id: 'disposable_email_check' },
// IP/Company Lookup
{ label: 'Email to IP', id: 'email_to_ip' },
{ label: 'IP to Company', id: 'ip_to_company' },
// Company Enrichment
{ label: 'Company Lookup', id: 'company_lookup' },
{ label: 'Company Funding & Traffic', id: 'company_funding' },
{ label: 'Company Revenue', id: 'company_revenue' },
// Search
{ label: 'Search People', id: 'search_people' },
{ label: 'Search Company', id: 'search_company' },
{ label: 'Search Company Employees', id: 'search_company_employees' },
{ label: 'Search Similar Companies', id: 'search_similar_companies' },
{ label: 'Sales Pointer (People)', id: 'sales_pointer_people' },
// LinkedIn Posts/Activities
{ label: 'Search Posts', id: 'search_posts' },
{ label: 'Get Post Details', id: 'get_post_details' },
{ label: 'Search Post Reactions', id: 'search_post_reactions' },
{ label: 'Search Post Comments', id: 'search_post_comments' },
{ label: 'Search People Activities', id: 'search_people_activities' },
{ label: 'Search Company Activities', id: 'search_company_activities' },
// Other
{ label: 'Reverse Hash Lookup', id: 'reverse_hash_lookup' },
{ label: 'Search Logo', id: 'search_logo' },
{ label: 'Check Credits', id: 'check_credits' },
],
value: () => 'email_to_profile',
},
{
id: 'apiKey',
title: 'Enrich API Key',
type: 'short-input',
placeholder: 'Enter your Enrich.so API key',
password: true,
required: true,
},
{
id: 'email',
title: 'Email Address',
type: 'short-input',
placeholder: 'john.doe@company.com',
condition: {
field: 'operation',
value: [
'email_to_profile',
'email_to_person_lite',
'email_to_phone',
'verify_email',
'disposable_email_check',
'email_to_ip',
],
},
required: {
field: 'operation',
value: [
'email_to_profile',
'email_to_person_lite',
'email_to_phone',
'verify_email',
'disposable_email_check',
'email_to_ip',
],
},
},
{
id: 'inRealtime',
title: 'Fetch Fresh Data',
type: 'switch',
condition: { field: 'operation', value: 'email_to_profile' },
mode: 'advanced',
},
{
id: 'linkedinUrl',
title: 'LinkedIn Profile URL',
type: 'short-input',
placeholder: 'linkedin.com/in/williamhgates',
condition: {
field: 'operation',
value: [
'linkedin_profile',
'linkedin_to_work_email',
'linkedin_to_personal_email',
'phone_finder',
],
},
required: {
field: 'operation',
value: [
'linkedin_profile',
'linkedin_to_work_email',
'linkedin_to_personal_email',
'phone_finder',
],
},
},
{
id: 'fullName',
title: 'Full Name',
type: 'short-input',
placeholder: 'John Doe',
condition: { field: 'operation', value: 'find_email' },
required: { field: 'operation', value: 'find_email' },
},
{
id: 'companyDomain',
title: 'Company Domain',
type: 'short-input',
placeholder: 'example.com',
condition: { field: 'operation', value: 'find_email' },
required: { field: 'operation', value: 'find_email' },
},
{
id: 'ip',
title: 'IP Address',
type: 'short-input',
placeholder: '86.92.60.221',
condition: { field: 'operation', value: 'ip_to_company' },
required: { field: 'operation', value: 'ip_to_company' },
},
{
id: 'companyName',
title: 'Company Name',
type: 'short-input',
placeholder: 'Google',
condition: { field: 'operation', value: 'company_lookup' },
},
{
id: 'domain',
title: 'Domain',
type: 'short-input',
placeholder: 'google.com',
condition: {
field: 'operation',
value: ['company_lookup', 'company_funding', 'company_revenue', 'search_logo'],
},
required: {
field: 'operation',
value: ['company_funding', 'company_revenue', 'search_logo'],
},
},
{
id: 'firstName',
title: 'First Name',
type: 'short-input',
placeholder: 'John',
condition: { field: 'operation', value: 'search_people' },
},
{
id: 'lastName',
title: 'Last Name',
type: 'short-input',
placeholder: 'Doe',
condition: { field: 'operation', value: 'search_people' },
},
{
id: 'subTitle',
title: 'Job Title',
type: 'short-input',
placeholder: 'Software Engineer',
condition: { field: 'operation', value: 'search_people' },
},
{
id: 'locationCountry',
title: 'Country',
type: 'short-input',
placeholder: 'United States',
condition: { field: 'operation', value: ['search_people', 'search_company'] },
},
{
id: 'locationCity',
title: 'City',
type: 'short-input',
placeholder: 'San Francisco',
condition: { field: 'operation', value: ['search_people', 'search_company'] },
},
{
id: 'industry',
title: 'Industry',
type: 'short-input',
placeholder: 'Technology',
condition: { field: 'operation', value: 'search_people' },
},
{
id: 'currentJobTitles',
title: 'Current Job Titles (JSON)',
type: 'code',
placeholder: '["CEO", "CTO", "VP Engineering"]',
condition: { field: 'operation', value: 'search_people' },
},
{
id: 'skills',
title: 'Skills (JSON)',
type: 'code',
placeholder: '["Python", "Machine Learning"]',
condition: { field: 'operation', value: 'search_people' },
},
{
id: 'searchCompanyName',
title: 'Company Name',
type: 'short-input',
placeholder: 'Google',
condition: { field: 'operation', value: 'search_company' },
},
{
id: 'industries',
title: 'Industries (JSON)',
type: 'code',
placeholder: '["Technology", "Software"]',
condition: { field: 'operation', value: 'search_company' },
},
{
id: 'staffCountMin',
title: 'Min Employees',
type: 'short-input',
placeholder: '50',
condition: { field: 'operation', value: 'search_company' },
},
{
id: 'staffCountMax',
title: 'Max Employees',
type: 'short-input',
placeholder: '500',
condition: { field: 'operation', value: 'search_company' },
},
{
id: 'companyIds',
title: 'Company IDs (JSON)',
type: 'code',
placeholder: '[12345, 67890]',
condition: { field: 'operation', value: 'search_company_employees' },
},
{
id: 'country',
title: 'Country',
type: 'short-input',
placeholder: 'United States',
condition: { field: 'operation', value: 'search_company_employees' },
},
{
id: 'city',
title: 'City',
type: 'short-input',
placeholder: 'San Francisco',
condition: { field: 'operation', value: 'search_company_employees' },
},
{
id: 'jobTitles',
title: 'Job Titles (JSON)',
type: 'code',
placeholder: '["Software Engineer", "Product Manager"]',
condition: { field: 'operation', value: 'search_company_employees' },
},
{
id: 'linkedinCompanyUrl',
title: 'LinkedIn Company URL',
type: 'short-input',
placeholder: 'linkedin.com/company/google',
condition: { field: 'operation', value: 'search_similar_companies' },
required: { field: 'operation', value: 'search_similar_companies' },
},
{
id: 'accountLocation',
title: 'Locations (JSON)',
type: 'code',
placeholder: '["germany", "france"]',
condition: { field: 'operation', value: 'search_similar_companies' },
},
{
id: 'employeeSizeType',
title: 'Employee Size Filter Type',
type: 'dropdown',
options: [
{ label: 'Range', id: 'RANGE' },
{ label: 'Exact', id: 'EXACT' },
],
condition: { field: 'operation', value: 'search_similar_companies' },
mode: 'advanced',
},
{
id: 'employeeSizeRange',
title: 'Employee Size Range (JSON)',
type: 'code',
placeholder: '[{"start": 50, "end": 200}]',
condition: { field: 'operation', value: 'search_similar_companies' },
},
{
id: 'num',
title: 'Results Per Page',
type: 'short-input',
placeholder: '10',
condition: { field: 'operation', value: 'search_similar_companies' },
},
{
id: 'filters',
title: 'Filters (JSON)',
type: 'code',
placeholder:
'[{"type": "POSTAL_CODE", "values": [{"id": "101041448", "text": "San Francisco", "selectionType": "INCLUDED"}]}]',
condition: { field: 'operation', value: 'sales_pointer_people' },
required: { field: 'operation', value: 'sales_pointer_people' },
},
{
id: 'keywords',
title: 'Keywords',
type: 'short-input',
placeholder: 'AI automation',
condition: { field: 'operation', value: 'search_posts' },
required: { field: 'operation', value: 'search_posts' },
},
{
id: 'datePosted',
title: 'Date Posted',
type: 'dropdown',
options: [
{ label: 'Any time', id: '' },
{ label: 'Past 24 hours', id: 'past_24_hours' },
{ label: 'Past week', id: 'past_week' },
{ label: 'Past month', id: 'past_month' },
],
condition: { field: 'operation', value: 'search_posts' },
},
{
id: 'postUrl',
title: 'LinkedIn Post URL',
type: 'short-input',
placeholder: 'https://www.linkedin.com/posts/...',
condition: { field: 'operation', value: 'get_post_details' },
required: { field: 'operation', value: 'get_post_details' },
},
{
id: 'postUrn',
title: 'Post URN',
type: 'short-input',
placeholder: 'urn:li:activity:7231931952839196672',
condition: {
field: 'operation',
value: ['search_post_reactions', 'search_post_comments'],
},
required: {
field: 'operation',
value: ['search_post_reactions', 'search_post_comments'],
},
},
{
id: 'reactionType',
title: 'Reaction Type',
type: 'dropdown',
options: [
{ label: 'All', id: 'all' },
{ label: 'Like', id: 'like' },
{ label: 'Love', id: 'love' },
{ label: 'Celebrate', id: 'celebrate' },
{ label: 'Insightful', id: 'insightful' },
{ label: 'Funny', id: 'funny' },
],
condition: { field: 'operation', value: 'search_post_reactions' },
},
{
id: 'profileId',
title: 'Profile ID',
type: 'short-input',
placeholder: 'ACoAAC1wha0BhoDIRAHrP5rgzVDyzmSdnl-KuEk',
condition: { field: 'operation', value: 'search_people_activities' },
required: { field: 'operation', value: 'search_people_activities' },
},
{
id: 'activityType',
title: 'Activity Type',
type: 'dropdown',
options: [
{ label: 'Posts', id: 'posts' },
{ label: 'Comments', id: 'comments' },
{ label: 'Articles', id: 'articles' },
],
condition: {
field: 'operation',
value: ['search_people_activities', 'search_company_activities'],
},
},
{
id: 'companyId',
title: 'Company ID',
type: 'short-input',
placeholder: '100746430',
condition: { field: 'operation', value: 'search_company_activities' },
required: { field: 'operation', value: 'search_company_activities' },
},
{
id: 'offset',
title: 'Offset',
type: 'short-input',
placeholder: '0',
condition: { field: 'operation', value: 'search_company_activities' },
mode: 'advanced',
},
{
id: 'hash',
title: 'MD5 Hash',
type: 'short-input',
placeholder: '5f0efb20de5ecfedbe0bf5e7c12353fe',
condition: { field: 'operation', value: 'reverse_hash_lookup' },
required: { field: 'operation', value: 'reverse_hash_lookup' },
},
{
id: 'page',
title: 'Page Number',
type: 'short-input',
placeholder: '1',
condition: {
field: 'operation',
value: [
'search_people',
'search_company',
'search_company_employees',
'search_similar_companies',
'sales_pointer_people',
'search_posts',
'search_post_reactions',
'search_post_comments',
],
},
required: { field: 'operation', value: 'sales_pointer_people' },
},
{
id: 'pageSize',
title: 'Results Per Page',
type: 'short-input',
placeholder: '20',
condition: {
field: 'operation',
value: ['search_people', 'search_company', 'search_company_employees'],
},
},
{
id: 'paginationToken',
title: 'Pagination Token',
type: 'short-input',
placeholder: 'Token from previous response',
condition: {
field: 'operation',
value: ['search_people_activities', 'search_company_activities'],
},
mode: 'advanced',
},
],
tools: {
access: [
'enrich_check_credits',
'enrich_email_to_profile',
'enrich_email_to_person_lite',
'enrich_linkedin_profile',
'enrich_find_email',
'enrich_linkedin_to_work_email',
'enrich_linkedin_to_personal_email',
'enrich_phone_finder',
'enrich_email_to_phone',
'enrich_verify_email',
'enrich_disposable_email_check',
'enrich_email_to_ip',
'enrich_ip_to_company',
'enrich_company_lookup',
'enrich_company_funding',
'enrich_company_revenue',
'enrich_search_people',
'enrich_search_company',
'enrich_search_company_employees',
'enrich_search_similar_companies',
'enrich_sales_pointer_people',
'enrich_search_posts',
'enrich_get_post_details',
'enrich_search_post_reactions',
'enrich_search_post_comments',
'enrich_search_people_activities',
'enrich_search_company_activities',
'enrich_reverse_hash_lookup',
'enrich_search_logo',
],
config: {
tool: (params) => `enrich_${params.operation}`,
params: (params) => {
const { operation, ...rest } = params
const parsedParams: Record<string, any> = { ...rest }
try {
if (rest.currentJobTitles && typeof rest.currentJobTitles === 'string') {
parsedParams.currentJobTitles = JSON.parse(rest.currentJobTitles)
}
if (rest.skills && typeof rest.skills === 'string') {
parsedParams.skills = JSON.parse(rest.skills)
}
if (rest.industries && typeof rest.industries === 'string') {
parsedParams.industries = JSON.parse(rest.industries)
}
if (rest.companyIds && typeof rest.companyIds === 'string') {
parsedParams.companyIds = JSON.parse(rest.companyIds)
}
if (rest.jobTitles && typeof rest.jobTitles === 'string') {
parsedParams.jobTitles = JSON.parse(rest.jobTitles)
}
if (rest.accountLocation && typeof rest.accountLocation === 'string') {
parsedParams.accountLocation = JSON.parse(rest.accountLocation)
}
if (rest.employeeSizeRange && typeof rest.employeeSizeRange === 'string') {
parsedParams.employeeSizeRange = JSON.parse(rest.employeeSizeRange)
}
if (rest.filters && typeof rest.filters === 'string') {
parsedParams.filters = JSON.parse(rest.filters)
}
} catch (error: any) {
throw new Error(`Invalid JSON input: ${error.message}`)
}
if (operation === 'linkedin_profile') {
parsedParams.url = rest.linkedinUrl
parsedParams.linkedinUrl = undefined
}
if (
operation === 'linkedin_to_work_email' ||
operation === 'linkedin_to_personal_email' ||
operation === 'phone_finder'
) {
parsedParams.linkedinProfile = rest.linkedinUrl
parsedParams.linkedinUrl = undefined
}
if (operation === 'company_lookup') {
parsedParams.name = rest.companyName
parsedParams.companyName = undefined
}
if (operation === 'search_company') {
parsedParams.name = rest.searchCompanyName
parsedParams.searchCompanyName = undefined
}
if (operation === 'search_similar_companies') {
parsedParams.url = rest.linkedinCompanyUrl
parsedParams.linkedinCompanyUrl = undefined
}
if (operation === 'get_post_details') {
parsedParams.url = rest.postUrl
parsedParams.postUrl = undefined
}
if (operation === 'search_logo') {
parsedParams.url = rest.domain
}
if (parsedParams.page) {
const pageNum = Number(parsedParams.page)
if (operation === 'search_people' || operation === 'search_company') {
parsedParams.currentPage = pageNum
parsedParams.page = undefined
} else {
parsedParams.page = pageNum
}
}
if (parsedParams.pageSize) parsedParams.pageSize = Number(parsedParams.pageSize)
if (parsedParams.num) parsedParams.num = Number(parsedParams.num)
if (parsedParams.offset) parsedParams.offset = Number(parsedParams.offset)
if (parsedParams.staffCountMin)
parsedParams.staffCountMin = Number(parsedParams.staffCountMin)
if (parsedParams.staffCountMax)
parsedParams.staffCountMax = Number(parsedParams.staffCountMax)
return parsedParams
},
},
},
inputs: {
operation: { type: 'string', description: 'Enrich operation to perform' },
},
outputs: {
success: { type: 'boolean', description: 'Whether the operation was successful' },
output: { type: 'json', description: 'Output data from the Enrich operation' },
},
}

View File

@@ -26,7 +26,6 @@ import { DuckDuckGoBlock } from '@/blocks/blocks/duckduckgo'
import { DynamoDBBlock } from '@/blocks/blocks/dynamodb' import { DynamoDBBlock } from '@/blocks/blocks/dynamodb'
import { ElasticsearchBlock } from '@/blocks/blocks/elasticsearch' import { ElasticsearchBlock } from '@/blocks/blocks/elasticsearch'
import { ElevenLabsBlock } from '@/blocks/blocks/elevenlabs' import { ElevenLabsBlock } from '@/blocks/blocks/elevenlabs'
import { EnrichBlock } from '@/blocks/blocks/enrich'
import { EvaluatorBlock } from '@/blocks/blocks/evaluator' import { EvaluatorBlock } from '@/blocks/blocks/evaluator'
import { ExaBlock } from '@/blocks/blocks/exa' import { ExaBlock } from '@/blocks/blocks/exa'
import { FileBlock, FileV2Block } from '@/blocks/blocks/file' import { FileBlock, FileV2Block } from '@/blocks/blocks/file'
@@ -189,7 +188,6 @@ export const registry: Record<string, BlockConfig> = {
dynamodb: DynamoDBBlock, dynamodb: DynamoDBBlock,
elasticsearch: ElasticsearchBlock, elasticsearch: ElasticsearchBlock,
elevenlabs: ElevenLabsBlock, elevenlabs: ElevenLabsBlock,
enrich: EnrichBlock,
evaluator: EvaluatorBlock, evaluator: EvaluatorBlock,
exa: ExaBlock, exa: ExaBlock,
file: FileBlock, file: FileBlock,

View File

@@ -458,8 +458,8 @@ export function getCodeEditorProps(options?: {
'caret-[var(--text-primary)] dark:caret-white', 'caret-[var(--text-primary)] dark:caret-white',
// Font smoothing // Font smoothing
'[-webkit-font-smoothing:antialiased] [-moz-osx-font-smoothing:grayscale]', '[-webkit-font-smoothing:antialiased] [-moz-osx-font-smoothing:grayscale]',
// Disable interaction for streaming/preview // Disable interaction for streaming/preview/disabled
(isStreaming || isPreview) && 'pointer-events-none' (isStreaming || isPreview || disabled) && 'pointer-events-none'
), ),
} }
} }

View File

@@ -5421,18 +5421,3 @@ z'
</svg> </svg>
) )
} }
export function EnrichSoIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 398 394' fill='none'>
<path
fill='#5A52F4'
d='M129.705566,319.705719 C127.553314,322.684906 125.651512,325.414673 123.657059,328.277466 C113.748466,318.440308 105.605003,310.395905 97.510834,302.302216 C93.625801,298.417419 89.990181,294.269318 85.949242,290.558868 C82.857994,287.720428 82.464081,285.757660 85.772888,282.551880 C104.068108,264.826202 122.146088,246.876312 140.285110,228.989670 C141.183945,228.103317 141.957443,227.089844 143.588837,225.218384 C140.691605,225.066116 138.820053,224.882874 136.948410,224.881958 C102.798264,224.865326 68.647453,224.765244 34.498699,224.983612 C29.315699,225.016739 27.990419,223.343155 28.090912,218.397430 C28.381887,204.076935 28.189890,189.746719 28.195684,175.420319 C28.198524,168.398178 28.319166,168.279541 35.590389,168.278687 C69.074188,168.274780 102.557991,168.281174 136.041794,168.266083 C137.968231,168.265213 139.894608,168.107101 141.821030,168.022171 C142.137955,167.513992 142.454895,167.005829 142.771820,166.497650 C122.842415,146.495621 102.913002,126.493591 83.261360,106.770348 C96.563828,93.471756 109.448814,80.590523 122.656265,67.386925 C123.522743,68.161835 124.785545,69.187096 125.930321,70.330513 C144.551819,88.930206 163.103683,107.600082 181.805267,126.118790 C186.713593,130.979126 189.085648,136.448059 189.055374,143.437057 C188.899490,179.418961 188.911179,215.402191 189.046661,251.384262 C189.072296,258.190796 186.742920,263.653717 181.982727,268.323273 C164.624405,285.351227 147.295807,302.409485 129.705566,319.705719z'
/>
<path
fill='#5A52F4'
d='M276.070923,246.906128 C288.284363,258.985870 300.156097,270.902100 312.235931,282.603485 C315.158752,285.434784 315.417542,287.246246 312.383484,290.248932 C301.143494,301.372498 290.168549,312.763733 279.075592,324.036255 C278.168030,324.958496 277.121307,325.743835 275.898315,326.801086 C274.628357,325.711792 273.460663,324.822968 272.422150,323.802673 C253.888397,305.594757 235.418701,287.321289 216.818268,269.181854 C211.508789,264.003937 208.872726,258.136688 208.914001,250.565842 C209.108337,214.917786 209.084808,179.267715 208.928864,143.619293 C208.898407,136.654907 211.130066,131.122162 216.052216,126.246094 C234.867538,107.606842 253.537521,88.820908 272.274780,70.102730 C273.313202,69.065353 274.468597,68.145027 275.264038,67.440727 C288.353516,80.579514 301.213470,93.487869 314.597534,106.922356 C295.163391,126.421753 275.214752,146.437363 255.266113,166.452972 C255.540176,166.940353 255.814240,167.427734 256.088318,167.915100 C257.983887,168.035736 259.879425,168.260345 261.775085,168.261551 C295.425201,168.282852 329.075287,168.273544 362.725403,168.279831 C369.598907,168.281113 369.776215,168.463593 369.778931,175.252213 C369.784882,189.911667 369.646088,204.573074 369.861206,219.229355 C369.925110,223.585022 368.554596,224.976288 364.148865,224.956406 C329.833130,224.801605 295.516388,224.869598 261.199951,224.868744 C259.297974,224.868698 257.396027,224.868744 254.866638,224.868744 C262.350708,232.658707 269.078217,239.661194 276.070923,246.906128z'
/>
</svg>
)
}

View File

@@ -212,11 +212,11 @@ export class WorkflowBlockHandler implements BlockHandler {
/** /**
* Parses a potentially nested workflow error message to extract: * Parses a potentially nested workflow error message to extract:
* - The chain of workflow names * - The chain of workflow names
* - The actual root error message (preserving the block prefix for the failing block) * - The actual root error message (preserving the block name prefix for the failing block)
* *
* Handles formats like: * Handles formats like:
* - "workflow-name" failed: error * - "workflow-name" failed: error
* - [block_type] Block Name: "workflow-name" failed: error * - Block Name: "workflow-name" failed: error
* - Workflow chain: A → B | error * - Workflow chain: A → B | error
*/ */
private parseNestedWorkflowError(message: string): { chain: string[]; rootError: string } { private parseNestedWorkflowError(message: string): { chain: string[]; rootError: string } {
@@ -234,8 +234,8 @@ export class WorkflowBlockHandler implements BlockHandler {
// Extract workflow names from patterns like: // Extract workflow names from patterns like:
// - "workflow-name" failed: // - "workflow-name" failed:
// - [block_type] Block Name: "workflow-name" failed: // - Block Name: "workflow-name" failed:
const workflowPattern = /(?:\[[^\]]+\]\s*[^:]+:\s*)?"([^"]+)"\s*failed:\s*/g const workflowPattern = /(?:\[[^\]]+\]\s*)?(?:[^:]+:\s*)?"([^"]+)"\s*failed:\s*/g
let match: RegExpExecArray | null let match: RegExpExecArray | null
let lastIndex = 0 let lastIndex = 0
@@ -247,7 +247,7 @@ export class WorkflowBlockHandler implements BlockHandler {
} }
// The root error is everything after the last match // The root error is everything after the last match
// Keep the block prefix (e.g., [function] Function 1:) so we know which block failed // Keep the block name prefix (e.g., Function 1:) so we know which block failed
const rootError = lastIndex > 0 ? remaining.slice(lastIndex) : remaining const rootError = lastIndex > 0 ? remaining.slice(lastIndex) : remaining
return { chain, rootError: rootError.trim() || 'Unknown error' } return { chain, rootError: rootError.trim() || 'Unknown error' }

View File

@@ -47,7 +47,7 @@ export function buildBlockExecutionError(details: BlockExecutionErrorDetails): E
const blockName = details.block.metadata?.name || details.block.id const blockName = details.block.metadata?.name || details.block.id
const blockType = details.block.metadata?.id || 'unknown' const blockType = details.block.metadata?.id || 'unknown'
const error = new Error(`[${blockType}] ${blockName}: ${errorMessage}`) const error = new Error(`${blockName}: ${errorMessage}`)
Object.assign(error, { Object.assign(error, {
blockId: details.block.id, blockId: details.block.id,

View File

@@ -409,6 +409,20 @@ export function useCollaborativeWorkflow() {
logger.info('Successfully applied batch-toggle-handles from remote user') logger.info('Successfully applied batch-toggle-handles from remote user')
break break
} }
case BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED: {
const { blockIds } = payload
logger.info('Received batch-toggle-locked from remote user', {
userId,
count: (blockIds || []).length,
})
if (blockIds && blockIds.length > 0) {
useWorkflowStore.getState().batchToggleLocked(blockIds)
}
logger.info('Successfully applied batch-toggle-locked from remote user')
break
}
case BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT: { case BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT: {
const { updates } = payload const { updates } = payload
logger.info('Received batch-update-parent from remote user', { logger.info('Received batch-update-parent from remote user', {
@@ -823,14 +837,27 @@ export function useCollaborativeWorkflow() {
if (ids.length === 0) return if (ids.length === 0) return
const currentBlocks = useWorkflowStore.getState().blocks
const previousStates: Record<string, boolean> = {} const previousStates: Record<string, boolean> = {}
const validIds: string[] = [] const validIds: string[] = []
// For each ID, collect non-locked blocks and their children for undo/redo
for (const id of ids) { for (const id of ids) {
const block = useWorkflowStore.getState().blocks[id] const block = currentBlocks[id]
if (block) { if (!block) continue
previousStates[id] = block.enabled
validIds.push(id) // Skip locked blocks
if (block.locked) continue
validIds.push(id)
previousStates[id] = block.enabled
// If it's a loop or parallel, also capture children's previous states for undo/redo
if (block.type === 'loop' || block.type === 'parallel') {
Object.entries(currentBlocks).forEach(([blockId, b]) => {
if (b.data?.parentId === id && !b.locked) {
previousStates[blockId] = b.enabled
}
})
} }
} }
@@ -992,12 +1019,25 @@ export function useCollaborativeWorkflow() {
if (ids.length === 0) return if (ids.length === 0) return
const blocks = useWorkflowStore.getState().blocks
// Helper to check if a block is protected (locked or inside locked parent)
const isProtected = (blockId: string): boolean => {
const block = blocks[blockId]
if (!block) return false
if (block.locked) return true
const parentId = block.data?.parentId
if (parentId && blocks[parentId]?.locked) return true
return false
}
const previousStates: Record<string, boolean> = {} const previousStates: Record<string, boolean> = {}
const validIds: string[] = [] const validIds: string[] = []
for (const id of ids) { for (const id of ids) {
const block = useWorkflowStore.getState().blocks[id] const block = blocks[id]
if (block) { // Skip locked blocks and blocks inside locked containers
if (block && !isProtected(id)) {
previousStates[id] = block.horizontalHandles ?? false previousStates[id] = block.horizontalHandles ?? false
validIds.push(id) validIds.push(id)
} }
@@ -1025,6 +1065,58 @@ export function useCollaborativeWorkflow() {
[isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo] [isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo]
) )
const collaborativeBatchToggleLocked = useCallback(
(ids: string[]) => {
if (isBaselineDiffView) {
return
}
if (ids.length === 0) return
const currentBlocks = useWorkflowStore.getState().blocks
const previousStates: Record<string, boolean> = {}
const validIds: string[] = []
// For each ID, collect blocks and their children for undo/redo
for (const id of ids) {
const block = currentBlocks[id]
if (!block) continue
validIds.push(id)
previousStates[id] = block.locked ?? false
// If it's a loop or parallel, also capture children's previous states for undo/redo
if (block.type === 'loop' || block.type === 'parallel') {
Object.entries(currentBlocks).forEach(([blockId, b]) => {
if (b.data?.parentId === id) {
previousStates[blockId] = b.locked ?? false
}
})
}
}
if (validIds.length === 0) return
const operationId = crypto.randomUUID()
addToQueue({
id: operationId,
operation: {
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED,
target: OPERATION_TARGETS.BLOCKS,
payload: { blockIds: validIds, previousStates },
},
workflowId: activeWorkflowId || '',
userId: session?.user?.id || 'unknown',
})
useWorkflowStore.getState().batchToggleLocked(validIds)
undoRedo.recordBatchToggleLocked(validIds, previousStates)
},
[isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo]
)
const collaborativeBatchAddEdges = useCallback( const collaborativeBatchAddEdges = useCallback(
(edges: Edge[], options?: { skipUndoRedo?: boolean }) => { (edges: Edge[], options?: { skipUndoRedo?: boolean }) => {
if (isBaselineDiffView) { if (isBaselineDiffView) {
@@ -1669,6 +1761,7 @@ export function useCollaborativeWorkflow() {
collaborativeToggleBlockAdvancedMode, collaborativeToggleBlockAdvancedMode,
collaborativeSetBlockCanonicalMode, collaborativeSetBlockCanonicalMode,
collaborativeBatchToggleBlockHandles, collaborativeBatchToggleBlockHandles,
collaborativeBatchToggleLocked,
collaborativeBatchAddBlocks, collaborativeBatchAddBlocks,
collaborativeBatchRemoveBlocks, collaborativeBatchRemoveBlocks,
collaborativeBatchAddEdges, collaborativeBatchAddEdges,

View File

@@ -20,6 +20,7 @@ import {
type BatchRemoveEdgesOperation, type BatchRemoveEdgesOperation,
type BatchToggleEnabledOperation, type BatchToggleEnabledOperation,
type BatchToggleHandlesOperation, type BatchToggleHandlesOperation,
type BatchToggleLockedOperation,
type BatchUpdateParentOperation, type BatchUpdateParentOperation,
captureLatestEdges, captureLatestEdges,
captureLatestSubBlockValues, captureLatestSubBlockValues,
@@ -415,6 +416,36 @@ export function useUndoRedo() {
[activeWorkflowId, userId] [activeWorkflowId, userId]
) )
const recordBatchToggleLocked = useCallback(
(blockIds: string[], previousStates: Record<string, boolean>) => {
if (!activeWorkflowId || blockIds.length === 0) return
const operation: BatchToggleLockedOperation = {
id: crypto.randomUUID(),
type: UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED,
timestamp: Date.now(),
workflowId: activeWorkflowId,
userId,
data: { blockIds, previousStates },
}
const inverse: BatchToggleLockedOperation = {
id: crypto.randomUUID(),
type: UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED,
timestamp: Date.now(),
workflowId: activeWorkflowId,
userId,
data: { blockIds, previousStates },
}
const entry = createOperationEntry(operation, inverse)
useUndoRedoStore.getState().push(activeWorkflowId, userId, entry)
logger.debug('Recorded batch toggle locked', { blockIds, previousStates })
},
[activeWorkflowId, userId]
)
const undo = useCallback(async () => { const undo = useCallback(async () => {
if (!activeWorkflowId) return if (!activeWorkflowId) return
@@ -777,7 +808,9 @@ export function useUndoRedo() {
const toggleOp = entry.inverse as BatchToggleEnabledOperation const toggleOp = entry.inverse as BatchToggleEnabledOperation
const { blockIds, previousStates } = toggleOp.data const { blockIds, previousStates } = toggleOp.data
const validBlockIds = blockIds.filter((id) => useWorkflowStore.getState().blocks[id]) // Restore all blocks in previousStates (includes children of containers)
const allBlockIds = Object.keys(previousStates)
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
if (validBlockIds.length === 0) { if (validBlockIds.length === 0) {
logger.debug('Undo batch-toggle-enabled skipped; no blocks exist') logger.debug('Undo batch-toggle-enabled skipped; no blocks exist')
break break
@@ -788,14 +821,14 @@ export function useUndoRedo() {
operation: { operation: {
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED, operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
target: OPERATION_TARGETS.BLOCKS, target: OPERATION_TARGETS.BLOCKS,
payload: { blockIds: validBlockIds, previousStates }, payload: { blockIds, previousStates },
}, },
workflowId: activeWorkflowId, workflowId: activeWorkflowId,
userId, userId,
}) })
// Use setBlockEnabled to directly restore to previous state // Use setBlockEnabled to directly restore to previous state
// This is more robust than conditional toggle in collaborative scenarios // This restores all affected blocks including children of containers
validBlockIds.forEach((blockId) => { validBlockIds.forEach((blockId) => {
useWorkflowStore.getState().setBlockEnabled(blockId, previousStates[blockId]) useWorkflowStore.getState().setBlockEnabled(blockId, previousStates[blockId])
}) })
@@ -829,6 +862,36 @@ export function useUndoRedo() {
}) })
break break
} }
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED: {
const toggleOp = entry.inverse as BatchToggleLockedOperation
const { blockIds, previousStates } = toggleOp.data
// Restore all blocks in previousStates (includes children of containers)
const allBlockIds = Object.keys(previousStates)
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
if (validBlockIds.length === 0) {
logger.debug('Undo batch-toggle-locked skipped; no blocks exist')
break
}
addToQueue({
id: opId,
operation: {
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED,
target: OPERATION_TARGETS.BLOCKS,
payload: { blockIds, previousStates },
},
workflowId: activeWorkflowId,
userId,
})
// Use setBlockLocked to directly restore to previous state
// This restores all affected blocks including children of containers
validBlockIds.forEach((blockId) => {
useWorkflowStore.getState().setBlockLocked(blockId, previousStates[blockId])
})
break
}
case UNDO_REDO_OPERATIONS.APPLY_DIFF: { case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
const applyDiffInverse = entry.inverse as any const applyDiffInverse = entry.inverse as any
const { baselineSnapshot } = applyDiffInverse.data const { baselineSnapshot } = applyDiffInverse.data
@@ -1365,7 +1428,9 @@ export function useUndoRedo() {
const toggleOp = entry.operation as BatchToggleEnabledOperation const toggleOp = entry.operation as BatchToggleEnabledOperation
const { blockIds, previousStates } = toggleOp.data const { blockIds, previousStates } = toggleOp.data
const validBlockIds = blockIds.filter((id) => useWorkflowStore.getState().blocks[id]) // Process all blocks in previousStates (includes children of containers)
const allBlockIds = Object.keys(previousStates)
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
if (validBlockIds.length === 0) { if (validBlockIds.length === 0) {
logger.debug('Redo batch-toggle-enabled skipped; no blocks exist') logger.debug('Redo batch-toggle-enabled skipped; no blocks exist')
break break
@@ -1376,16 +1441,18 @@ export function useUndoRedo() {
operation: { operation: {
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED, operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
target: OPERATION_TARGETS.BLOCKS, target: OPERATION_TARGETS.BLOCKS,
payload: { blockIds: validBlockIds, previousStates }, payload: { blockIds, previousStates },
}, },
workflowId: activeWorkflowId, workflowId: activeWorkflowId,
userId, userId,
}) })
// Use setBlockEnabled to directly set to toggled state // Compute target state the same way batchToggleEnabled does:
// Redo sets to !previousStates (the state after the original toggle) // use !firstBlock.enabled, where firstBlock is blockIds[0]
const firstBlockId = blockIds[0]
const targetEnabled = !previousStates[firstBlockId]
validBlockIds.forEach((blockId) => { validBlockIds.forEach((blockId) => {
useWorkflowStore.getState().setBlockEnabled(blockId, !previousStates[blockId]) useWorkflowStore.getState().setBlockEnabled(blockId, targetEnabled)
}) })
break break
} }
@@ -1417,6 +1484,38 @@ export function useUndoRedo() {
}) })
break break
} }
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED: {
const toggleOp = entry.operation as BatchToggleLockedOperation
const { blockIds, previousStates } = toggleOp.data
// Process all blocks in previousStates (includes children of containers)
const allBlockIds = Object.keys(previousStates)
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
if (validBlockIds.length === 0) {
logger.debug('Redo batch-toggle-locked skipped; no blocks exist')
break
}
addToQueue({
id: opId,
operation: {
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED,
target: OPERATION_TARGETS.BLOCKS,
payload: { blockIds, previousStates },
},
workflowId: activeWorkflowId,
userId,
})
// Compute target state the same way batchToggleLocked does:
// use !firstBlock.locked, where firstBlock is blockIds[0]
const firstBlockId = blockIds[0]
const targetLocked = !previousStates[firstBlockId]
validBlockIds.forEach((blockId) => {
useWorkflowStore.getState().setBlockLocked(blockId, targetLocked)
})
break
}
case UNDO_REDO_OPERATIONS.APPLY_DIFF: { case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
// Redo apply-diff means re-applying the proposed state with diff markers // Redo apply-diff means re-applying the proposed state with diff markers
const applyDiffOp = entry.operation as any const applyDiffOp = entry.operation as any
@@ -1738,6 +1837,7 @@ export function useUndoRedo() {
recordBatchUpdateParent, recordBatchUpdateParent,
recordBatchToggleEnabled, recordBatchToggleEnabled,
recordBatchToggleHandles, recordBatchToggleHandles,
recordBatchToggleLocked,
recordApplyDiff, recordApplyDiff,
recordAcceptDiff, recordAcceptDiff,
recordRejectDiff, recordRejectDiff,

View File

@@ -54,6 +54,7 @@ type SkippedItemType =
| 'block_not_found' | 'block_not_found'
| 'invalid_block_type' | 'invalid_block_type'
| 'block_not_allowed' | 'block_not_allowed'
| 'block_locked'
| 'tool_not_allowed' | 'tool_not_allowed'
| 'invalid_edge_target' | 'invalid_edge_target'
| 'invalid_edge_source' | 'invalid_edge_source'
@@ -618,6 +619,7 @@ function createBlockFromParams(
subBlocks: {}, subBlocks: {},
outputs: outputs, outputs: outputs,
data: parentId ? { parentId, extent: 'parent' as const } : {}, data: parentId ? { parentId, extent: 'parent' as const } : {},
locked: false,
} }
// Add validated inputs as subBlocks // Add validated inputs as subBlocks
@@ -1520,6 +1522,24 @@ function applyOperationsToWorkflowState(
break break
} }
// Check if block is locked or inside a locked container
const deleteBlock = modifiedState.blocks[block_id]
const deleteParentId = deleteBlock.data?.parentId as string | undefined
const deleteParentLocked = deleteParentId
? modifiedState.blocks[deleteParentId]?.locked
: false
if (deleteBlock.locked || deleteParentLocked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'delete',
blockId: block_id,
reason: deleteParentLocked
? `Block "${block_id}" is inside locked container "${deleteParentId}" and cannot be deleted`
: `Block "${block_id}" is locked and cannot be deleted`,
})
break
}
// Find all child blocks to remove // Find all child blocks to remove
const blocksToRemove = new Set<string>([block_id]) const blocksToRemove = new Set<string>([block_id])
const findChildren = (parentId: string) => { const findChildren = (parentId: string) => {
@@ -1555,6 +1575,21 @@ function applyOperationsToWorkflowState(
const block = modifiedState.blocks[block_id] const block = modifiedState.blocks[block_id]
// Check if block is locked or inside a locked container
const editParentId = block.data?.parentId as string | undefined
const editParentLocked = editParentId ? modifiedState.blocks[editParentId]?.locked : false
if (block.locked || editParentLocked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'edit',
blockId: block_id,
reason: editParentLocked
? `Block "${block_id}" is inside locked container "${editParentId}" and cannot be edited`
: `Block "${block_id}" is locked and cannot be edited`,
})
break
}
// Ensure block has essential properties // Ensure block has essential properties
if (!block.type) { if (!block.type) {
logger.warn(`Block ${block_id} missing type property, skipping edit`, { logger.warn(`Block ${block_id} missing type property, skipping edit`, {
@@ -2122,6 +2157,19 @@ function applyOperationsToWorkflowState(
// Handle nested nodes (for loops/parallels created from scratch) // Handle nested nodes (for loops/parallels created from scratch)
if (params.nestedNodes) { if (params.nestedNodes) {
// Defensive check: verify parent is not locked before adding children
// (Parent was just created with locked: false, but check for consistency)
const parentBlock = modifiedState.blocks[block_id]
if (parentBlock?.locked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'add_nested_nodes',
blockId: block_id,
reason: `Container "${block_id}" is locked - cannot add nested nodes`,
})
break
}
Object.entries(params.nestedNodes).forEach(([childId, childBlock]: [string, any]) => { Object.entries(params.nestedNodes).forEach(([childId, childBlock]: [string, any]) => {
// Validate childId is a valid string // Validate childId is a valid string
if (!isValidKey(childId)) { if (!isValidKey(childId)) {
@@ -2209,6 +2257,18 @@ function applyOperationsToWorkflowState(
break break
} }
// Check if subflow is locked
if (subflowBlock.locked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'insert_into_subflow',
blockId: block_id,
reason: `Subflow "${subflowId}" is locked - cannot insert block "${block_id}"`,
details: { subflowId },
})
break
}
if (subflowBlock.type !== 'loop' && subflowBlock.type !== 'parallel') { if (subflowBlock.type !== 'loop' && subflowBlock.type !== 'parallel') {
logger.error('Subflow block has invalid type', { logger.error('Subflow block has invalid type', {
subflowId, subflowId,
@@ -2247,6 +2307,17 @@ function applyOperationsToWorkflowState(
break break
} }
// Check if existing block is locked
if (existingBlock.locked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'insert_into_subflow',
blockId: block_id,
reason: `Block "${block_id}" is locked and cannot be moved into a subflow`,
})
break
}
// Moving existing block into subflow - just update parent // Moving existing block into subflow - just update parent
existingBlock.data = { existingBlock.data = {
...existingBlock.data, ...existingBlock.data,
@@ -2392,6 +2463,30 @@ function applyOperationsToWorkflowState(
break break
} }
// Check if block is locked
if (block.locked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'extract_from_subflow',
blockId: block_id,
reason: `Block "${block_id}" is locked and cannot be extracted from subflow`,
})
break
}
// Check if parent subflow is locked
const parentSubflow = modifiedState.blocks[subflowId]
if (parentSubflow?.locked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'extract_from_subflow',
blockId: block_id,
reason: `Subflow "${subflowId}" is locked - cannot extract block "${block_id}"`,
details: { subflowId },
})
break
}
// Verify it's actually a child of this subflow // Verify it's actually a child of this subflow
if (block.data?.parentId !== subflowId) { if (block.data?.parentId !== subflowId) {
logger.warn('Block is not a child of specified subflow', { logger.warn('Block is not a child of specified subflow', {

View File

@@ -296,6 +296,26 @@ describe('hasWorkflowChanged', () => {
}) })
expect(hasWorkflowChanged(state1, state2)).toBe(true) expect(hasWorkflowChanged(state1, state2)).toBe(true)
}) })
it.concurrent('should detect locked/unlocked changes', () => {
const state1 = createWorkflowState({
blocks: { block1: createBlock('block1', { locked: false }) },
})
const state2 = createWorkflowState({
blocks: { block1: createBlock('block1', { locked: true }) },
})
expect(hasWorkflowChanged(state1, state2)).toBe(true)
})
it.concurrent('should not detect changes when locked state is the same', () => {
const state1 = createWorkflowState({
blocks: { block1: createBlock('block1', { locked: true }) },
})
const state2 = createWorkflowState({
blocks: { block1: createBlock('block1', { locked: true }) },
})
expect(hasWorkflowChanged(state1, state2)).toBe(false)
})
}) })
describe('SubBlock Changes', () => { describe('SubBlock Changes', () => {

View File

@@ -157,7 +157,7 @@ export function generateWorkflowDiffSummary(
} }
// Check other block properties (boolean fields) // Check other block properties (boolean fields)
// Use !! to normalize: null/undefined/false are all equivalent (falsy) // Use !! to normalize: null/undefined/false are all equivalent (falsy)
const blockFields = ['horizontalHandles', 'advancedMode', 'triggerMode'] as const const blockFields = ['horizontalHandles', 'advancedMode', 'triggerMode', 'locked'] as const
for (const field of blockFields) { for (const field of blockFields) {
if (!!currentBlock[field] !== !!previousBlock[field]) { if (!!currentBlock[field] !== !!previousBlock[field]) {
changes.push({ changes.push({

View File

@@ -100,6 +100,7 @@ function buildStartBlockState(
triggerMode: false, triggerMode: false,
height: 0, height: 0,
data: {}, data: {},
locked: false,
} }
return { blockState, subBlockValues } return { blockState, subBlockValues }

View File

@@ -0,0 +1,173 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
// Mock all external dependencies before imports
vi.mock('@sim/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}))
vi.mock('@/stores/workflows/workflow/store', () => ({
useWorkflowStore: {
getState: () => ({
getWorkflowState: () => ({ blocks: {}, edges: [], loops: {}, parallels: {} }),
}),
},
}))
vi.mock('@/stores/workflows/utils', () => ({
mergeSubblockState: (blocks: Record<string, BlockState>) => blocks,
}))
vi.mock('@/lib/workflows/sanitization/key-validation', () => ({
isValidKey: (key: string) => key !== 'undefined' && key !== 'null' && key !== '',
}))
vi.mock('@/lib/workflows/autolayout', () => ({
transferBlockHeights: vi.fn(),
applyTargetedLayout: (blocks: Record<string, BlockState>) => blocks,
applyAutoLayout: () => ({ success: true, blocks: {} }),
}))
vi.mock('@/lib/workflows/autolayout/constants', () => ({
DEFAULT_HORIZONTAL_SPACING: 500,
DEFAULT_VERTICAL_SPACING: 400,
DEFAULT_LAYOUT_OPTIONS: {},
}))
vi.mock('@/stores/workflows/workflow/utils', () => ({
generateLoopBlocks: () => ({}),
generateParallelBlocks: () => ({}),
}))
import { WorkflowDiffEngine } from './diff-engine'
function createMockBlock(overrides: Partial<BlockState> = {}): BlockState {
return {
id: 'block-1',
type: 'agent',
name: 'Test Block',
enabled: true,
position: { x: 0, y: 0 },
subBlocks: {},
outputs: {},
...overrides,
} as BlockState
}
function createMockWorkflowState(blocks: Record<string, BlockState>): WorkflowState {
return {
blocks,
edges: [],
loops: {},
parallels: {},
}
}
describe('WorkflowDiffEngine', () => {
let engine: WorkflowDiffEngine
beforeEach(() => {
engine = new WorkflowDiffEngine()
vi.clearAllMocks()
})
describe('hasBlockChanged detection', () => {
describe('locked state changes', () => {
it.concurrent(
'should detect when block locked state changes from false to true',
async () => {
const freshEngine = new WorkflowDiffEngine()
const baseline = createMockWorkflowState({
'block-1': createMockBlock({ id: 'block-1', locked: false }),
})
const proposed = createMockWorkflowState({
'block-1': createMockBlock({ id: 'block-1', locked: true }),
})
const result = await freshEngine.createDiffFromWorkflowState(
proposed,
undefined,
baseline
)
expect(result.success).toBe(true)
expect(result.diff?.diffAnalysis?.edited_blocks).toContain('block-1')
}
)
it.concurrent('should not detect change when locked state is the same', async () => {
const freshEngine = new WorkflowDiffEngine()
const baseline = createMockWorkflowState({
'block-1': createMockBlock({ id: 'block-1', locked: true }),
})
const proposed = createMockWorkflowState({
'block-1': createMockBlock({ id: 'block-1', locked: true }),
})
const result = await freshEngine.createDiffFromWorkflowState(proposed, undefined, baseline)
expect(result.success).toBe(true)
expect(result.diff?.diffAnalysis?.edited_blocks).not.toContain('block-1')
})
it.concurrent('should detect change when locked goes from undefined to true', async () => {
const freshEngine = new WorkflowDiffEngine()
const baseline = createMockWorkflowState({
'block-1': createMockBlock({ id: 'block-1' }), // locked undefined
})
const proposed = createMockWorkflowState({
'block-1': createMockBlock({ id: 'block-1', locked: true }),
})
const result = await freshEngine.createDiffFromWorkflowState(proposed, undefined, baseline)
expect(result.success).toBe(true)
// The hasBlockChanged function uses !!locked for comparison
// so undefined -> true should be detected as a change
expect(result.diff?.diffAnalysis?.edited_blocks).toContain('block-1')
})
it.concurrent('should not detect change when both locked states are falsy', async () => {
const freshEngine = new WorkflowDiffEngine()
const baseline = createMockWorkflowState({
'block-1': createMockBlock({ id: 'block-1' }), // locked undefined
})
const proposed = createMockWorkflowState({
'block-1': createMockBlock({ id: 'block-1', locked: false }), // locked false
})
const result = await freshEngine.createDiffFromWorkflowState(proposed, undefined, baseline)
expect(result.success).toBe(true)
// undefined and false should both be falsy, so !! comparison makes them equal
expect(result.diff?.diffAnalysis?.edited_blocks).not.toContain('block-1')
})
})
})
describe('diff lifecycle', () => {
it.concurrent('should start with no diff', () => {
const freshEngine = new WorkflowDiffEngine()
expect(freshEngine.hasDiff()).toBe(false)
expect(freshEngine.getCurrentDiff()).toBeUndefined()
})
it.concurrent('should clear diff', () => {
const freshEngine = new WorkflowDiffEngine()
freshEngine.clearDiff()
expect(freshEngine.hasDiff()).toBe(false)
})
})
})

View File

@@ -215,6 +215,7 @@ function hasBlockChanged(currentBlock: BlockState, proposedBlock: BlockState): b
if (currentBlock.name !== proposedBlock.name) return true if (currentBlock.name !== proposedBlock.name) return true
if (currentBlock.enabled !== proposedBlock.enabled) return true if (currentBlock.enabled !== proposedBlock.enabled) return true
if (currentBlock.triggerMode !== proposedBlock.triggerMode) return true if (currentBlock.triggerMode !== proposedBlock.triggerMode) return true
if (!!currentBlock.locked !== !!proposedBlock.locked) return true
// Compare subBlocks // Compare subBlocks
const currentSubKeys = Object.keys(currentBlock.subBlocks || {}) const currentSubKeys = Object.keys(currentBlock.subBlocks || {})

View File

@@ -189,6 +189,7 @@ export async function duplicateWorkflow(
parentId: newParentId, parentId: newParentId,
extent: newExtent, extent: newExtent,
data: updatedData, data: updatedData,
locked: false, // Duplicated blocks should always be unlocked
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
} }

View File

@@ -226,6 +226,7 @@ export async function loadWorkflowFromNormalizedTables(
subBlocks: (block.subBlocks as BlockState['subBlocks']) || {}, subBlocks: (block.subBlocks as BlockState['subBlocks']) || {},
outputs: (block.outputs as BlockState['outputs']) || {}, outputs: (block.outputs as BlockState['outputs']) || {},
data: blockData, data: blockData,
locked: block.locked,
} }
blocksMap[block.id] = assembled blocksMap[block.id] = assembled
@@ -363,6 +364,7 @@ export async function saveWorkflowToNormalizedTables(
data: block.data || {}, data: block.data || {},
parentId: block.data?.parentId || null, parentId: block.data?.parentId || null,
extent: block.data?.extent || null, extent: block.data?.extent || null,
locked: block.locked ?? false,
})) }))
await tx.insert(workflowBlocks).values(blockInserts) await tx.insert(workflowBlocks).values(blockInserts)
@@ -627,7 +629,8 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener
// Regenerate blocks with updated references // Regenerate blocks with updated references
Object.entries(state.blocks || {}).forEach(([oldId, block]) => { Object.entries(state.blocks || {}).forEach(([oldId, block]) => {
const newId = blockIdMapping.get(oldId)! const newId = blockIdMapping.get(oldId)!
const newBlock: BlockState = { ...block, id: newId } // Duplicated blocks are always unlocked so users can edit them
const newBlock: BlockState = { ...block, id: newId, locked: false }
// Update parentId reference if it exists // Update parentId reference if it exists
if (newBlock.data?.parentId) { if (newBlock.data?.parentId) {

View File

@@ -17,6 +17,7 @@ export const BLOCKS_OPERATIONS = {
BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled', BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled',
BATCH_TOGGLE_HANDLES: 'batch-toggle-handles', BATCH_TOGGLE_HANDLES: 'batch-toggle-handles',
BATCH_UPDATE_PARENT: 'batch-update-parent', BATCH_UPDATE_PARENT: 'batch-update-parent',
BATCH_TOGGLE_LOCKED: 'batch-toggle-locked',
} as const } as const
export type BlocksOperation = (typeof BLOCKS_OPERATIONS)[keyof typeof BLOCKS_OPERATIONS] export type BlocksOperation = (typeof BLOCKS_OPERATIONS)[keyof typeof BLOCKS_OPERATIONS]
@@ -85,6 +86,7 @@ export const UNDO_REDO_OPERATIONS = {
BATCH_UPDATE_PARENT: 'batch-update-parent', BATCH_UPDATE_PARENT: 'batch-update-parent',
BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled', BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled',
BATCH_TOGGLE_HANDLES: 'batch-toggle-handles', BATCH_TOGGLE_HANDLES: 'batch-toggle-handles',
BATCH_TOGGLE_LOCKED: 'batch-toggle-locked',
APPLY_DIFF: 'apply-diff', APPLY_DIFF: 'apply-diff',
ACCEPT_DIFF: 'accept-diff', ACCEPT_DIFF: 'accept-diff',
REJECT_DIFF: 'reject-diff', REJECT_DIFF: 'reject-diff',

View File

@@ -507,7 +507,37 @@ async function handleBlocksOperationTx(
}) })
if (blocks && blocks.length > 0) { if (blocks && blocks.length > 0) {
const blockValues = blocks.map((block: Record<string, unknown>) => { // Fetch existing blocks to check for locked parents
const existingBlocks = await tx
.select({ id: workflowBlocks.id, locked: workflowBlocks.locked })
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId))
type ExistingBlockRecord = (typeof existingBlocks)[number]
const lockedParentIds = new Set(
existingBlocks
.filter((b: ExistingBlockRecord) => b.locked)
.map((b: ExistingBlockRecord) => b.id)
)
// Filter out blocks being added to locked parents
const allowedBlocks = (blocks as Array<Record<string, unknown>>).filter((block) => {
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
| string
| undefined
if (parentId && lockedParentIds.has(parentId)) {
logger.info(`Skipping block ${block.id} - parent ${parentId} is locked`)
return false
}
return true
})
if (allowedBlocks.length === 0) {
logger.info('All blocks filtered out due to locked parents, skipping add')
break
}
const blockValues = allowedBlocks.map((block: Record<string, unknown>) => {
const blockId = block.id as string const blockId = block.id as string
const mergedSubBlocks = mergeSubBlockValues( const mergedSubBlocks = mergeSubBlockValues(
block.subBlocks as Record<string, unknown>, block.subBlocks as Record<string, unknown>,
@@ -529,6 +559,7 @@ async function handleBlocksOperationTx(
advancedMode: (block.advancedMode as boolean) ?? false, advancedMode: (block.advancedMode as boolean) ?? false,
triggerMode: (block.triggerMode as boolean) ?? false, triggerMode: (block.triggerMode as boolean) ?? false,
height: (block.height as number) || 0, height: (block.height as number) || 0,
locked: (block.locked as boolean) ?? false,
} }
}) })
@@ -537,7 +568,7 @@ async function handleBlocksOperationTx(
// Create subflow entries for loop/parallel blocks (skip if already in payload) // Create subflow entries for loop/parallel blocks (skip if already in payload)
const loopIds = new Set(loops ? Object.keys(loops) : []) const loopIds = new Set(loops ? Object.keys(loops) : [])
const parallelIds = new Set(parallels ? Object.keys(parallels) : []) const parallelIds = new Set(parallels ? Object.keys(parallels) : [])
for (const block of blocks) { for (const block of allowedBlocks) {
const blockId = block.id as string const blockId = block.id as string
if (block.type === 'loop' && !loopIds.has(blockId)) { if (block.type === 'loop' && !loopIds.has(blockId)) {
await tx.insert(workflowSubflows).values({ await tx.insert(workflowSubflows).values({
@@ -566,7 +597,7 @@ async function handleBlocksOperationTx(
// Update parent subflow node lists // Update parent subflow node lists
const parentIds = new Set<string>() const parentIds = new Set<string>()
for (const block of blocks) { for (const block of allowedBlocks) {
const parentId = (block.data as Record<string, unknown>)?.parentId as string | undefined const parentId = (block.data as Record<string, unknown>)?.parentId as string | undefined
if (parentId) { if (parentId) {
parentIds.add(parentId) parentIds.add(parentId)
@@ -624,44 +655,74 @@ async function handleBlocksOperationTx(
logger.info(`Batch removing ${ids.length} blocks from workflow ${workflowId}`) logger.info(`Batch removing ${ids.length} blocks from workflow ${workflowId}`)
// Fetch all blocks to check lock status and filter out protected blocks
const allBlocks = await tx
.select({
id: workflowBlocks.id,
type: workflowBlocks.type,
locked: workflowBlocks.locked,
data: workflowBlocks.data,
})
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId))
type BlockRecord = (typeof allBlocks)[number]
const blocksById: Record<string, BlockRecord> = Object.fromEntries(
allBlocks.map((b: BlockRecord) => [b.id, b])
)
// Helper to check if a block is protected (locked or inside locked parent)
const isProtected = (blockId: string): boolean => {
const block = blocksById[blockId]
if (!block) return false
if (block.locked) return true
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
| string
| undefined
if (parentId && blocksById[parentId]?.locked) return true
return false
}
// Filter out protected blocks from deletion request
const deletableIds = ids.filter((id) => !isProtected(id))
if (deletableIds.length === 0) {
logger.info('All requested blocks are protected, skipping deletion')
return
}
if (deletableIds.length < ids.length) {
logger.info(
`Filtered out ${ids.length - deletableIds.length} protected blocks from deletion`
)
}
// Collect all block IDs including children of subflows // Collect all block IDs including children of subflows
const allBlocksToDelete = new Set<string>(ids) const allBlocksToDelete = new Set<string>(deletableIds)
for (const id of ids) { for (const id of deletableIds) {
const blockToRemove = await tx const block = blocksById[id]
.select({ type: workflowBlocks.type }) if (block && isSubflowBlockType(block.type)) {
.from(workflowBlocks) // Include all children of the subflow (they should be deleted with parent)
.where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId))) for (const b of allBlocks) {
.limit(1) const parentId = (b.data as Record<string, unknown> | null)?.parentId
if (parentId === id) {
if (blockToRemove.length > 0 && isSubflowBlockType(blockToRemove[0].type)) { allBlocksToDelete.add(b.id)
const childBlocks = await tx }
.select({ id: workflowBlocks.id }) }
.from(workflowBlocks)
.where(
and(
eq(workflowBlocks.workflowId, workflowId),
sql`${workflowBlocks.data}->>'parentId' = ${id}`
)
)
childBlocks.forEach((child: { id: string }) => allBlocksToDelete.add(child.id))
} }
} }
const blockIdsArray = Array.from(allBlocksToDelete) const blockIdsArray = Array.from(allBlocksToDelete)
// Collect parent IDs BEFORE deleting blocks // Collect parent IDs BEFORE deleting blocks (use blocksById, already fetched)
const parentIds = new Set<string>() const parentIds = new Set<string>()
for (const id of ids) { for (const id of deletableIds) {
const parentInfo = await tx const block = blocksById[id]
.select({ parentId: sql<string | null>`${workflowBlocks.data}->>'parentId'` }) const parentId = (block?.data as Record<string, unknown> | null)?.parentId as
.from(workflowBlocks) | string
.where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId))) | undefined
.limit(1) if (parentId) {
parentIds.add(parentId)
if (parentInfo.length > 0 && parentInfo[0].parentId) {
parentIds.add(parentInfo[0].parentId)
} }
} }
@@ -741,22 +802,61 @@ async function handleBlocksOperationTx(
`Batch toggling enabled state for ${blockIds.length} blocks in workflow ${workflowId}` `Batch toggling enabled state for ${blockIds.length} blocks in workflow ${workflowId}`
) )
const blocks = await tx // Get all blocks in workflow to find children and check locked state
.select({ id: workflowBlocks.id, enabled: workflowBlocks.enabled }) const allBlocks = await tx
.select({
id: workflowBlocks.id,
enabled: workflowBlocks.enabled,
locked: workflowBlocks.locked,
type: workflowBlocks.type,
data: workflowBlocks.data,
})
.from(workflowBlocks) .from(workflowBlocks)
.where(and(eq(workflowBlocks.workflowId, workflowId), inArray(workflowBlocks.id, blockIds))) .where(eq(workflowBlocks.workflowId, workflowId))
for (const block of blocks) { type BlockRecord = (typeof allBlocks)[number]
const blocksById: Record<string, BlockRecord> = Object.fromEntries(
allBlocks.map((b: BlockRecord) => [b.id, b])
)
const blocksToToggle = new Set<string>()
// Collect all blocks to toggle including children of containers
for (const id of blockIds) {
const block = blocksById[id]
if (!block || block.locked) continue
blocksToToggle.add(id)
// If it's a loop or parallel, also include all children
if (block.type === 'loop' || block.type === 'parallel') {
for (const b of allBlocks) {
const parentId = (b.data as Record<string, unknown> | null)?.parentId
if (parentId === id && !b.locked) {
blocksToToggle.add(b.id)
}
}
}
}
// Determine target enabled state based on first toggleable block
if (blocksToToggle.size === 0) break
const firstToggleableId = Array.from(blocksToToggle)[0]
const firstBlock = blocksById[firstToggleableId]
if (!firstBlock) break
const targetEnabled = !firstBlock.enabled
// Update all affected blocks
for (const blockId of blocksToToggle) {
await tx await tx
.update(workflowBlocks) .update(workflowBlocks)
.set({ .set({
enabled: !block.enabled, enabled: targetEnabled,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(and(eq(workflowBlocks.id, block.id), eq(workflowBlocks.workflowId, workflowId))) .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
} }
logger.debug(`Batch toggled enabled state for ${blocks.length} blocks`) logger.debug(`Batch toggled enabled state for ${blocksToToggle.size} blocks`)
break break
} }
@@ -768,22 +868,118 @@ async function handleBlocksOperationTx(
logger.info(`Batch toggling handles for ${blockIds.length} blocks in workflow ${workflowId}`) logger.info(`Batch toggling handles for ${blockIds.length} blocks in workflow ${workflowId}`)
const blocks = await tx // Fetch all blocks to check lock status and filter out protected blocks
.select({ id: workflowBlocks.id, horizontalHandles: workflowBlocks.horizontalHandles }) const allBlocks = await tx
.select({
id: workflowBlocks.id,
horizontalHandles: workflowBlocks.horizontalHandles,
locked: workflowBlocks.locked,
data: workflowBlocks.data,
})
.from(workflowBlocks) .from(workflowBlocks)
.where(and(eq(workflowBlocks.workflowId, workflowId), inArray(workflowBlocks.id, blockIds))) .where(eq(workflowBlocks.workflowId, workflowId))
for (const block of blocks) { type HandleBlockRecord = (typeof allBlocks)[number]
const blocksById: Record<string, HandleBlockRecord> = Object.fromEntries(
allBlocks.map((b: HandleBlockRecord) => [b.id, b])
)
// Helper to check if a block is protected (locked or inside locked parent)
const isProtected = (blockId: string): boolean => {
const block = blocksById[blockId]
if (!block) return false
if (block.locked) return true
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
| string
| undefined
if (parentId && blocksById[parentId]?.locked) return true
return false
}
// Filter to only toggle handles on unprotected blocks
const blocksToToggle = blockIds.filter((id) => blocksById[id] && !isProtected(id))
if (blocksToToggle.length === 0) {
logger.info('All requested blocks are protected, skipping handles toggle')
break
}
for (const blockId of blocksToToggle) {
const block = blocksById[blockId]
await tx await tx
.update(workflowBlocks) .update(workflowBlocks)
.set({ .set({
horizontalHandles: !block.horizontalHandles, horizontalHandles: !block.horizontalHandles,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(and(eq(workflowBlocks.id, block.id), eq(workflowBlocks.workflowId, workflowId))) .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
} }
logger.debug(`Batch toggled handles for ${blocks.length} blocks`) logger.debug(`Batch toggled handles for ${blocksToToggle.length} blocks`)
break
}
case BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED: {
const { blockIds } = payload
if (!Array.isArray(blockIds) || blockIds.length === 0) {
return
}
logger.info(`Batch toggling locked for ${blockIds.length} blocks in workflow ${workflowId}`)
// Get all blocks in workflow to find children
const allBlocks = await tx
.select({
id: workflowBlocks.id,
locked: workflowBlocks.locked,
type: workflowBlocks.type,
data: workflowBlocks.data,
})
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId))
type LockedBlockRecord = (typeof allBlocks)[number]
const blocksById: Record<string, LockedBlockRecord> = Object.fromEntries(
allBlocks.map((b: LockedBlockRecord) => [b.id, b])
)
const blocksToToggle = new Set<string>()
// Collect all blocks to toggle including children of containers
for (const id of blockIds) {
const block = blocksById[id]
if (!block) continue
blocksToToggle.add(id)
// If it's a loop or parallel, also include all children
if (block.type === 'loop' || block.type === 'parallel') {
for (const b of allBlocks) {
const parentId = (b.data as Record<string, unknown> | null)?.parentId
if (parentId === id) {
blocksToToggle.add(b.id)
}
}
}
}
// Determine target locked state based on first toggleable block
if (blocksToToggle.size === 0) break
const firstToggleableId = Array.from(blocksToToggle)[0]
const firstBlock = blocksById[firstToggleableId]
if (!firstBlock) break
const targetLocked = !firstBlock.locked
// Update all affected blocks
for (const blockId of blocksToToggle) {
await tx
.update(workflowBlocks)
.set({
locked: targetLocked,
updatedAt: new Date(),
})
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
}
logger.debug(`Batch toggled locked for ${blocksToToggle.size} blocks`)
break break
} }
@@ -795,19 +991,54 @@ async function handleBlocksOperationTx(
logger.info(`Batch updating parent for ${updates.length} blocks in workflow ${workflowId}`) logger.info(`Batch updating parent for ${updates.length} blocks in workflow ${workflowId}`)
// Fetch all blocks to check lock status
const allBlocks = await tx
.select({
id: workflowBlocks.id,
locked: workflowBlocks.locked,
data: workflowBlocks.data,
})
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId))
type ParentBlockRecord = (typeof allBlocks)[number]
const blocksById: Record<string, ParentBlockRecord> = Object.fromEntries(
allBlocks.map((b: ParentBlockRecord) => [b.id, b])
)
// Helper to check if a block is protected (locked or inside locked parent)
const isProtected = (blockId: string): boolean => {
const block = blocksById[blockId]
if (!block) return false
if (block.locked) return true
const currentParentId = (block.data as Record<string, unknown> | null)?.parentId as
| string
| undefined
if (currentParentId && blocksById[currentParentId]?.locked) return true
return false
}
for (const update of updates) { for (const update of updates) {
const { id, parentId, position } = update const { id, parentId, position } = update
if (!id) continue if (!id) continue
// Skip protected blocks (locked or inside locked container)
if (isProtected(id)) {
logger.info(`Skipping block ${id} parent update - block is protected`)
continue
}
// Skip if trying to move into a locked container
if (parentId && blocksById[parentId]?.locked) {
logger.info(`Skipping block ${id} parent update - target parent ${parentId} is locked`)
continue
}
// Fetch current parent to update subflow node lists // Fetch current parent to update subflow node lists
const [existing] = await tx const existing = blocksById[id]
.select({ const existingParentId = (existing?.data as Record<string, unknown> | null)?.parentId as
id: workflowBlocks.id, | string
parentId: sql<string | null>`${workflowBlocks.data}->>'parentId'`, | undefined
})
.from(workflowBlocks)
.where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId)))
.limit(1)
if (!existing) { if (!existing) {
logger.warn(`Block ${id} not found for batch-update-parent`) logger.warn(`Block ${id} not found for batch-update-parent`)
@@ -852,8 +1083,8 @@ async function handleBlocksOperationTx(
await updateSubflowNodeList(tx, workflowId, parentId) await updateSubflowNodeList(tx, workflowId, parentId)
} }
// If the block had a previous parent, update that parent's node list as well // If the block had a previous parent, update that parent's node list as well
if (existing?.parentId && existing.parentId !== parentId) { if (existingParentId && existingParentId !== parentId) {
await updateSubflowNodeList(tx, workflowId, existing.parentId) await updateSubflowNodeList(tx, workflowId, existingParentId)
} }
} }
@@ -1198,6 +1429,7 @@ async function handleWorkflowOperationTx(
advancedMode: block.advancedMode ?? false, advancedMode: block.advancedMode ?? false,
triggerMode: block.triggerMode ?? false, triggerMode: block.triggerMode ?? false,
height: block.height || 0, height: block.height || 0,
locked: block.locked ?? false,
})) }))
await tx.insert(workflowBlocks).values(blockValues) await tx.insert(workflowBlocks).values(blockValues)

View File

@@ -214,6 +214,12 @@ describe('checkRolePermission', () => {
readAllowed: false, readAllowed: false,
}, },
{ operation: 'toggle-handles', adminAllowed: true, writeAllowed: true, readAllowed: false }, { operation: 'toggle-handles', adminAllowed: true, writeAllowed: true, readAllowed: false },
{
operation: 'batch-toggle-locked',
adminAllowed: true,
writeAllowed: false, // Admin-only operation
readAllowed: false,
},
{ {
operation: 'batch-update-positions', operation: 'batch-update-positions',
adminAllowed: true, adminAllowed: true,

View File

@@ -14,7 +14,10 @@ import {
const logger = createLogger('SocketPermissions') const logger = createLogger('SocketPermissions')
// All write operations (admin and write roles have same permissions) // Admin-only operations (require admin role)
const ADMIN_ONLY_OPERATIONS: string[] = [BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED]
// Write operations (admin and write roles both have these permissions)
const WRITE_OPERATIONS: string[] = [ const WRITE_OPERATIONS: string[] = [
// Block operations // Block operations
BLOCK_OPERATIONS.UPDATE_POSITION, BLOCK_OPERATIONS.UPDATE_POSITION,
@@ -51,7 +54,7 @@ const READ_OPERATIONS: string[] = [
// Define operation permissions based on role // Define operation permissions based on role
const ROLE_PERMISSIONS: Record<string, string[]> = { const ROLE_PERMISSIONS: Record<string, string[]> = {
admin: WRITE_OPERATIONS, admin: [...ADMIN_ONLY_OPERATIONS, ...WRITE_OPERATIONS],
write: WRITE_OPERATIONS, write: WRITE_OPERATIONS,
read: READ_OPERATIONS, read: READ_OPERATIONS,
} }

View File

@@ -208,6 +208,17 @@ export const BatchToggleHandlesSchema = z.object({
operationId: z.string().optional(), operationId: z.string().optional(),
}) })
export const BatchToggleLockedSchema = z.object({
operation: z.literal(BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED),
target: z.literal(OPERATION_TARGETS.BLOCKS),
payload: z.object({
blockIds: z.array(z.string()),
previousStates: z.record(z.boolean()),
}),
timestamp: z.number(),
operationId: z.string().optional(),
})
export const BatchUpdateParentSchema = z.object({ export const BatchUpdateParentSchema = z.object({
operation: z.literal(BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT), operation: z.literal(BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT),
target: z.literal(OPERATION_TARGETS.BLOCKS), target: z.literal(OPERATION_TARGETS.BLOCKS),
@@ -231,6 +242,7 @@ export const WorkflowOperationSchema = z.union([
BatchRemoveBlocksSchema, BatchRemoveBlocksSchema,
BatchToggleEnabledSchema, BatchToggleEnabledSchema,
BatchToggleHandlesSchema, BatchToggleHandlesSchema,
BatchToggleLockedSchema,
BatchUpdateParentSchema, BatchUpdateParentSchema,
EdgeOperationSchema, EdgeOperationSchema,
BatchAddEdgesSchema, BatchAddEdgesSchema,

View File

@@ -97,6 +97,14 @@ export interface BatchToggleHandlesOperation extends BaseOperation {
} }
} }
export interface BatchToggleLockedOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED
data: {
blockIds: string[]
previousStates: Record<string, boolean>
}
}
export interface ApplyDiffOperation extends BaseOperation { export interface ApplyDiffOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.APPLY_DIFF type: typeof UNDO_REDO_OPERATIONS.APPLY_DIFF
data: { data: {
@@ -136,6 +144,7 @@ export type Operation =
| BatchUpdateParentOperation | BatchUpdateParentOperation
| BatchToggleEnabledOperation | BatchToggleEnabledOperation
| BatchToggleHandlesOperation | BatchToggleHandlesOperation
| BatchToggleLockedOperation
| ApplyDiffOperation | ApplyDiffOperation
| AcceptDiffOperation | AcceptDiffOperation
| RejectDiffOperation | RejectDiffOperation

View File

@@ -167,6 +167,15 @@ export function createInverseOperation(operation: Operation): Operation {
}, },
} }
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED:
return {
...operation,
data: {
blockIds: operation.data.blockIds,
previousStates: operation.data.previousStates,
},
}
default: { default: {
const exhaustiveCheck: never = operation const exhaustiveCheck: never = operation
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`) throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`)

View File

@@ -432,4 +432,104 @@ describe('regenerateBlockIds', () => {
expect(duplicatedBlock.position).toEqual({ x: 280, y: 70 }) expect(duplicatedBlock.position).toEqual({ x: 280, y: 70 })
expect(duplicatedBlock.data?.parentId).toBe(loopId) expect(duplicatedBlock.data?.parentId).toBe(loopId)
}) })
it('should unlock pasted block when source is locked', () => {
const blockId = 'block-1'
const blocksToCopy = {
[blockId]: createAgentBlock({
id: blockId,
name: 'Locked Agent',
position: { x: 100, y: 50 },
locked: true,
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
// Pasted blocks are always unlocked so users can edit them
const pastedBlock = newBlocks[0]
expect(pastedBlock.locked).toBe(false)
})
it('should keep pasted block unlocked when source is unlocked', () => {
const blockId = 'block-1'
const blocksToCopy = {
[blockId]: createAgentBlock({
id: blockId,
name: 'Unlocked Agent',
position: { x: 100, y: 50 },
locked: false,
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
const pastedBlock = newBlocks[0]
expect(pastedBlock.locked).toBe(false)
})
it('should unlock all pasted blocks regardless of source locked state', () => {
const lockedId = 'locked-1'
const unlockedId = 'unlocked-1'
const blocksToCopy = {
[lockedId]: createAgentBlock({
id: lockedId,
name: 'Originally Locked Agent',
position: { x: 100, y: 50 },
locked: true,
}),
[unlockedId]: createFunctionBlock({
id: unlockedId,
name: 'Originally Unlocked Function',
position: { x: 200, y: 50 },
locked: false,
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(2)
// All pasted blocks should be unlocked so users can edit them
for (const block of newBlocks) {
expect(block.locked).toBe(false)
}
})
}) })

View File

@@ -203,6 +203,7 @@ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState
advancedMode: false, advancedMode: false,
triggerMode, triggerMode,
height: 0, height: 0,
locked: false,
} }
} }
@@ -481,6 +482,8 @@ export function regenerateBlockIds(
position: newPosition, position: newPosition,
// Temporarily keep data as-is, we'll fix parentId in second pass // Temporarily keep data as-is, we'll fix parentId in second pass
data: block.data ? { ...block.data } : block.data, data: block.data ? { ...block.data } : block.data,
// Duplicated blocks are always unlocked so users can edit them
locked: false,
} }
newBlocks[newId] = newBlock newBlocks[newId] = newBlock
@@ -508,15 +511,15 @@ export function regenerateBlockIds(
parentId: newParentId, parentId: newParentId,
extent: 'parent', extent: 'parent',
} }
} else if (existingBlockNames[oldParentId]) { } else if (existingBlockNames[oldParentId] && !existingBlockNames[oldParentId].locked) {
// Parent exists in existing workflow - keep original parentId (block stays in same subflow) // Parent exists in existing workflow and is not locked - keep original parentId
block.data = { block.data = {
...block.data, ...block.data,
parentId: oldParentId, parentId: oldParentId,
extent: 'parent', extent: 'parent',
} }
} else { } else {
// Parent doesn't exist anywhere - clear the relationship // Parent doesn't exist anywhere OR parent is locked - clear the relationship
block.data = { ...block.data, parentId: undefined, extent: undefined } block.data = { ...block.data, parentId: undefined, extent: undefined }
} }
} }

View File

@@ -1144,6 +1144,223 @@ describe('workflow store', () => {
}) })
}) })
describe('batchToggleLocked', () => {
it('should toggle block locked state', () => {
const { batchToggleLocked } = useWorkflowStore.getState()
addBlock('block-1', 'function', 'Test', { x: 0, y: 0 })
// Initial state is undefined (falsy)
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBeFalsy()
batchToggleLocked(['block-1'])
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBe(true)
batchToggleLocked(['block-1'])
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBe(false)
})
it('should cascade lock to children when locking a loop', () => {
const { batchToggleLocked } = useWorkflowStore.getState()
addBlock('loop-1', 'loop', 'My Loop', { x: 0, y: 0 }, { loopType: 'for', count: 3 })
addBlock(
'child-1',
'function',
'Child',
{ x: 50, y: 50 },
{ parentId: 'loop-1' },
'loop-1',
'parent'
)
batchToggleLocked(['loop-1'])
const { blocks } = useWorkflowStore.getState()
expect(blocks['loop-1'].locked).toBe(true)
expect(blocks['child-1'].locked).toBe(true)
})
it('should cascade unlock to children when unlocking a parallel', () => {
const { batchToggleLocked } = useWorkflowStore.getState()
addBlock('parallel-1', 'parallel', 'My Parallel', { x: 0, y: 0 }, { count: 3 })
addBlock(
'child-1',
'function',
'Child',
{ x: 50, y: 50 },
{ parentId: 'parallel-1' },
'parallel-1',
'parent'
)
// Lock first
batchToggleLocked(['parallel-1'])
expect(useWorkflowStore.getState().blocks['child-1'].locked).toBe(true)
// Unlock
batchToggleLocked(['parallel-1'])
const { blocks } = useWorkflowStore.getState()
expect(blocks['parallel-1'].locked).toBe(false)
expect(blocks['child-1'].locked).toBe(false)
})
it('should toggle multiple blocks at once', () => {
const { batchToggleLocked } = useWorkflowStore.getState()
addBlock('block-1', 'function', 'Test 1', { x: 0, y: 0 })
addBlock('block-2', 'function', 'Test 2', { x: 100, y: 0 })
batchToggleLocked(['block-1', 'block-2'])
const { blocks } = useWorkflowStore.getState()
expect(blocks['block-1'].locked).toBe(true)
expect(blocks['block-2'].locked).toBe(true)
})
})
describe('setBlockLocked', () => {
it('should set block locked state', () => {
const { setBlockLocked } = useWorkflowStore.getState()
addBlock('block-1', 'function', 'Test', { x: 0, y: 0 })
setBlockLocked('block-1', true)
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBe(true)
setBlockLocked('block-1', false)
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBe(false)
})
it('should not update if locked state is already the target value', () => {
const { setBlockLocked } = useWorkflowStore.getState()
addBlock('block-1', 'function', 'Test', { x: 0, y: 0 })
// First set to true
setBlockLocked('block-1', true)
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBe(true)
// Setting to true again should still be true
setBlockLocked('block-1', true)
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBe(true)
})
})
describe('duplicateBlock with locked', () => {
it('should unlock duplicate when duplicating a locked block', () => {
const { setBlockLocked, duplicateBlock } = useWorkflowStore.getState()
addBlock('original', 'agent', 'Original Agent', { x: 0, y: 0 })
setBlockLocked('original', true)
expect(useWorkflowStore.getState().blocks.original.locked).toBe(true)
duplicateBlock('original')
const { blocks } = useWorkflowStore.getState()
const blockIds = Object.keys(blocks)
expect(blockIds.length).toBe(2)
const duplicatedId = blockIds.find((id) => id !== 'original')
expect(duplicatedId).toBeDefined()
if (duplicatedId) {
// Original should still be locked
expect(blocks.original.locked).toBe(true)
// Duplicate should be unlocked so users can edit it
expect(blocks[duplicatedId].locked).toBe(false)
}
})
it('should create unlocked duplicate when duplicating an unlocked block', () => {
const { duplicateBlock } = useWorkflowStore.getState()
addBlock('original', 'agent', 'Original Agent', { x: 0, y: 0 })
duplicateBlock('original')
const { blocks } = useWorkflowStore.getState()
const blockIds = Object.keys(blocks)
const duplicatedId = blockIds.find((id) => id !== 'original')
if (duplicatedId) {
expect(blocks[duplicatedId].locked).toBeFalsy()
}
})
it('should place duplicate outside locked container when duplicating block inside locked loop', () => {
const { batchToggleLocked, duplicateBlock } = useWorkflowStore.getState()
// Create a loop with a child block
addBlock('loop-1', 'loop', 'My Loop', { x: 0, y: 0 }, { loopType: 'for', count: 3 })
addBlock(
'child-1',
'function',
'Child',
{ x: 50, y: 50 },
{ parentId: 'loop-1' },
'loop-1',
'parent'
)
// Lock the loop (which cascades to the child)
batchToggleLocked(['loop-1'])
expect(useWorkflowStore.getState().blocks['child-1'].locked).toBe(true)
// Duplicate the child block
duplicateBlock('child-1')
const { blocks } = useWorkflowStore.getState()
const blockIds = Object.keys(blocks)
expect(blockIds.length).toBe(3) // loop, original child, duplicate
const duplicatedId = blockIds.find((id) => id !== 'loop-1' && id !== 'child-1')
expect(duplicatedId).toBeDefined()
if (duplicatedId) {
// Duplicate should be unlocked
expect(blocks[duplicatedId].locked).toBe(false)
// Duplicate should NOT have a parentId (placed outside the locked container)
expect(blocks[duplicatedId].data?.parentId).toBeUndefined()
// Original should still be inside the loop
expect(blocks['child-1'].data?.parentId).toBe('loop-1')
}
})
it('should keep duplicate inside unlocked container when duplicating block inside unlocked loop', () => {
const { duplicateBlock } = useWorkflowStore.getState()
// Create a loop with a child block (not locked)
addBlock('loop-1', 'loop', 'My Loop', { x: 0, y: 0 }, { loopType: 'for', count: 3 })
addBlock(
'child-1',
'function',
'Child',
{ x: 50, y: 50 },
{ parentId: 'loop-1' },
'loop-1',
'parent'
)
// Duplicate the child block (loop is NOT locked)
duplicateBlock('child-1')
const { blocks } = useWorkflowStore.getState()
const blockIds = Object.keys(blocks)
const duplicatedId = blockIds.find((id) => id !== 'loop-1' && id !== 'child-1')
if (duplicatedId) {
// Duplicate should still be inside the loop since it's not locked
expect(blocks[duplicatedId].data?.parentId).toBe('loop-1')
}
})
})
describe('updateBlockName', () => { describe('updateBlockName', () => {
beforeEach(() => { beforeEach(() => {
useWorkflowStore.setState({ useWorkflowStore.setState({

View File

@@ -207,6 +207,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
triggerMode?: boolean triggerMode?: boolean
height?: number height?: number
data?: Record<string, any> data?: Record<string, any>
locked?: boolean
}>, }>,
edges?: Edge[], edges?: Edge[],
subBlockValues?: Record<string, Record<string, unknown>>, subBlockValues?: Record<string, Record<string, unknown>>,
@@ -231,6 +232,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
triggerMode: block.triggerMode ?? false, triggerMode: block.triggerMode ?? false,
height: block.height ?? 0, height: block.height ?? 0,
data: block.data, data: block.data,
locked: block.locked ?? false,
} }
} }
@@ -365,24 +367,69 @@ export const useWorkflowStore = create<WorkflowStore>()(
}, },
batchToggleEnabled: (ids: string[]) => { batchToggleEnabled: (ids: string[]) => {
const newBlocks = { ...get().blocks } if (ids.length === 0) return
const currentBlocks = get().blocks
const newBlocks = { ...currentBlocks }
const blocksToToggle = new Set<string>()
// For each ID, collect blocks to toggle (skip locked blocks entirely)
// If it's a container, also include non-locked children
for (const id of ids) { for (const id of ids) {
if (newBlocks[id]) { const block = currentBlocks[id]
newBlocks[id] = { ...newBlocks[id], enabled: !newBlocks[id].enabled } if (!block) continue
// Skip locked blocks entirely (including their children)
if (block.locked) continue
blocksToToggle.add(id)
// If it's a loop or parallel, also include non-locked children
if (block.type === 'loop' || block.type === 'parallel') {
Object.entries(currentBlocks).forEach(([blockId, b]) => {
if (b.data?.parentId === id && !b.locked) {
blocksToToggle.add(blockId)
}
})
} }
} }
// If no blocks can be toggled, exit early
if (blocksToToggle.size === 0) return
// Determine target enabled state based on first toggleable block
const firstToggleableId = Array.from(blocksToToggle)[0]
const firstBlock = currentBlocks[firstToggleableId]
const targetEnabled = !firstBlock.enabled
// Apply the enabled state to all toggleable blocks
for (const blockId of blocksToToggle) {
newBlocks[blockId] = { ...newBlocks[blockId], enabled: targetEnabled }
}
set({ blocks: newBlocks, edges: [...get().edges] }) set({ blocks: newBlocks, edges: [...get().edges] })
get().updateLastSaved() get().updateLastSaved()
}, },
batchToggleHandles: (ids: string[]) => { batchToggleHandles: (ids: string[]) => {
const newBlocks = { ...get().blocks } const currentBlocks = get().blocks
const newBlocks = { ...currentBlocks }
// Helper to check if a block is protected (locked or inside locked parent)
const isProtected = (blockId: string): boolean => {
const block = currentBlocks[blockId]
if (!block) return false
if (block.locked) return true
const parentId = block.data?.parentId
if (parentId && currentBlocks[parentId]?.locked) return true
return false
}
for (const id of ids) { for (const id of ids) {
if (newBlocks[id]) { if (!newBlocks[id] || isProtected(id)) continue
newBlocks[id] = { newBlocks[id] = {
...newBlocks[id], ...newBlocks[id],
horizontalHandles: !newBlocks[id].horizontalHandles, horizontalHandles: !newBlocks[id].horizontalHandles,
}
} }
} }
set({ blocks: newBlocks, edges: [...get().edges] }) set({ blocks: newBlocks, edges: [...get().edges] })
@@ -527,9 +574,33 @@ export const useWorkflowStore = create<WorkflowStore>()(
if (!block) return if (!block) return
const newId = crypto.randomUUID() const newId = crypto.randomUUID()
const offsetPosition = {
x: block.position.x + DEFAULT_DUPLICATE_OFFSET.x, // Check if block is inside a locked container - if so, place duplicate outside
y: block.position.y + DEFAULT_DUPLICATE_OFFSET.y, const parentId = block.data?.parentId
const parentBlock = parentId ? get().blocks[parentId] : undefined
const isParentLocked = parentBlock?.locked ?? false
// If parent is locked, calculate position outside the container
let offsetPosition: Position
const newData = block.data ? { ...block.data } : undefined
if (isParentLocked && parentBlock) {
// Place duplicate outside the locked container (to the right of it)
const containerWidth = parentBlock.data?.width ?? 400
offsetPosition = {
x: parentBlock.position.x + containerWidth + 50,
y: parentBlock.position.y,
}
// Remove parent relationship since we're placing outside
if (newData) {
newData.parentId = undefined
newData.extent = undefined
}
} else {
offsetPosition = {
x: block.position.x + DEFAULT_DUPLICATE_OFFSET.x,
y: block.position.y + DEFAULT_DUPLICATE_OFFSET.y,
}
} }
const newName = getUniqueBlockName(block.name, get().blocks) const newName = getUniqueBlockName(block.name, get().blocks)
@@ -557,6 +628,8 @@ export const useWorkflowStore = create<WorkflowStore>()(
name: newName, name: newName,
position: offsetPosition, position: offsetPosition,
subBlocks: newSubBlocks, subBlocks: newSubBlocks,
locked: false,
data: newData,
}, },
}, },
edges: [...get().edges], edges: [...get().edges],
@@ -1164,6 +1237,70 @@ export const useWorkflowStore = create<WorkflowStore>()(
getDragStartPosition: () => { getDragStartPosition: () => {
return get().dragStartPosition || null return get().dragStartPosition || null
}, },
setBlockLocked: (id: string, locked: boolean) => {
const block = get().blocks[id]
if (!block || block.locked === locked) return
const newState = {
blocks: {
...get().blocks,
[id]: {
...block,
locked,
},
},
edges: [...get().edges],
loops: { ...get().loops },
parallels: { ...get().parallels },
}
set(newState)
get().updateLastSaved()
},
batchToggleLocked: (ids: string[]) => {
if (ids.length === 0) return
const currentBlocks = get().blocks
const newBlocks = { ...currentBlocks }
const blocksToToggle = new Set<string>()
// For each ID, collect blocks to toggle
// If it's a container, also include all children
for (const id of ids) {
const block = currentBlocks[id]
if (!block) continue
blocksToToggle.add(id)
// If it's a loop or parallel, also include all children
if (block.type === 'loop' || block.type === 'parallel') {
Object.entries(currentBlocks).forEach(([blockId, b]) => {
if (b.data?.parentId === id) {
blocksToToggle.add(blockId)
}
})
}
}
// If no blocks found, exit early
if (blocksToToggle.size === 0) return
// Determine target locked state based on first block in original ids
const firstBlock = currentBlocks[ids[0]]
if (!firstBlock) return
const targetLocked = !firstBlock.locked
// Apply the locked state to all blocks
for (const blockId of blocksToToggle) {
newBlocks[blockId] = { ...newBlocks[blockId], locked: targetLocked }
}
set({ blocks: newBlocks, edges: [...get().edges] })
get().updateLastSaved()
},
}), }),
{ name: 'workflow-store' } { name: 'workflow-store' }
) )

View File

@@ -87,6 +87,7 @@ export interface BlockState {
triggerMode?: boolean triggerMode?: boolean
data?: BlockData data?: BlockData
layout?: BlockLayoutState layout?: BlockLayoutState
locked?: boolean
} }
export interface SubBlockState { export interface SubBlockState {
@@ -131,6 +132,7 @@ export interface Loop {
whileCondition?: string // JS expression that evaluates to boolean (for while loops) whileCondition?: string // JS expression that evaluates to boolean (for while loops)
doWhileCondition?: string // JS expression that evaluates to boolean (for do-while loops) doWhileCondition?: string // JS expression that evaluates to boolean (for do-while loops)
enabled: boolean enabled: boolean
locked?: boolean
} }
export interface Parallel { export interface Parallel {
@@ -140,6 +142,7 @@ export interface Parallel {
count?: number // Number of parallel executions for count-based parallel count?: number // Number of parallel executions for count-based parallel
parallelType?: 'count' | 'collection' // Explicit parallel type to avoid inference bugs parallelType?: 'count' | 'collection' // Explicit parallel type to avoid inference bugs
enabled: boolean enabled: boolean
locked?: boolean
} }
export interface Variable { export interface Variable {
@@ -233,6 +236,8 @@ export interface WorkflowActions {
workflowState: WorkflowState, workflowState: WorkflowState,
options?: { updateLastSaved?: boolean } options?: { updateLastSaved?: boolean }
) => void ) => void
setBlockLocked: (id: string, locked: boolean) => void
batchToggleLocked: (ids: string[]) => void
} }
export type WorkflowStore = WorkflowState & WorkflowActions export type WorkflowStore = WorkflowState & WorkflowActions

View File

@@ -1,55 +0,0 @@
import type { EnrichCheckCreditsParams, EnrichCheckCreditsResponse } from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const checkCreditsTool: ToolConfig<EnrichCheckCreditsParams, EnrichCheckCreditsResponse> = {
id: 'enrich_check_credits',
name: 'Enrich Check Credits',
description: 'Check your Enrich API credit usage and remaining balance.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
},
request: {
url: 'https://api.enrich.so/v1/api/auth',
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
totalCredits: data.total_credits ?? 0,
creditsUsed: data.credits_used ?? 0,
creditsRemaining: data.credits_remaining ?? 0,
},
}
},
outputs: {
totalCredits: {
type: 'number',
description: 'Total credits allocated to the account',
},
creditsUsed: {
type: 'number',
description: 'Credits consumed so far',
},
creditsRemaining: {
type: 'number',
description: 'Available credits remaining',
},
},
}

View File

@@ -1,143 +0,0 @@
import type { EnrichCompanyFundingParams, EnrichCompanyFundingResponse } from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const companyFundingTool: ToolConfig<
EnrichCompanyFundingParams,
EnrichCompanyFundingResponse
> = {
id: 'enrich_company_funding',
name: 'Enrich Company Funding',
description:
'Retrieve company funding history, traffic metrics, and executive information by domain.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
domain: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Company domain (e.g., example.com)',
},
},
request: {
url: (params) => {
const url = new URL('https://api.enrich.so/v1/api/company-funding-plus')
url.searchParams.append('domain', params.domain.trim())
return url.toString()
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const resultData = data.data ?? data
const fundingRounds =
(resultData.fundingRounds ?? resultData.funding_rounds)?.map((round: any) => ({
roundType: round.roundType ?? round.round_type ?? '',
amount: round.amount ?? null,
date: round.date ?? null,
investors: round.investors ?? [],
})) ?? []
const executives = (resultData.executives ?? []).map((exec: any) => ({
name: exec.name ?? exec.fullName ?? '',
title: exec.title ?? '',
}))
return {
success: true,
output: {
legalName: resultData.legalName ?? resultData.legal_name ?? null,
employeeCount: resultData.employeeCount ?? resultData.employee_count ?? null,
headquarters: resultData.headquarters ?? null,
industry: resultData.industry ?? null,
totalFundingRaised:
resultData.totalFundingRaised ?? resultData.total_funding_raised ?? null,
fundingRounds,
monthlyVisits: resultData.monthlyVisits ?? resultData.monthly_visits ?? null,
trafficChange: resultData.trafficChange ?? resultData.traffic_change ?? null,
itSpending: resultData.itSpending ?? resultData.it_spending ?? null,
executives,
},
}
},
outputs: {
legalName: {
type: 'string',
description: 'Legal company name',
optional: true,
},
employeeCount: {
type: 'number',
description: 'Number of employees',
optional: true,
},
headquarters: {
type: 'string',
description: 'Headquarters location',
optional: true,
},
industry: {
type: 'string',
description: 'Industry',
optional: true,
},
totalFundingRaised: {
type: 'number',
description: 'Total funding raised',
optional: true,
},
fundingRounds: {
type: 'array',
description: 'Funding rounds',
items: {
type: 'object',
properties: {
roundType: { type: 'string', description: 'Round type' },
amount: { type: 'number', description: 'Amount raised' },
date: { type: 'string', description: 'Date' },
investors: { type: 'array', description: 'Investors' },
},
},
},
monthlyVisits: {
type: 'number',
description: 'Monthly website visits',
optional: true,
},
trafficChange: {
type: 'number',
description: 'Traffic change percentage',
optional: true,
},
itSpending: {
type: 'number',
description: 'Estimated IT spending in USD',
optional: true,
},
executives: {
type: 'array',
description: 'Executive team',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Name' },
title: { type: 'string', description: 'Title' },
},
},
},
},
}

View File

@@ -1,197 +0,0 @@
import type { EnrichCompanyLookupParams, EnrichCompanyLookupResponse } from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const companyLookupTool: ToolConfig<EnrichCompanyLookupParams, EnrichCompanyLookupResponse> =
{
id: 'enrich_company_lookup',
name: 'Enrich Company Lookup',
description:
'Look up comprehensive company information by name or domain including funding, location, and social profiles.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
name: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Company name (e.g., Google)',
},
domain: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Company domain (e.g., google.com)',
},
},
request: {
url: (params) => {
const url = new URL('https://api.enrich.so/v1/api/company')
if (params.name) {
url.searchParams.append('name', params.name.trim())
}
if (params.domain) {
url.searchParams.append('domain', params.domain.trim())
}
return url.toString()
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const fundingRounds =
data.fundingData?.map((round: any) => ({
roundType: round.fundingRound ?? '',
amount: round.moneyRaised?.amount ?? null,
currency: round.moneyRaised?.currency ?? null,
investors: round.investors ?? [],
})) ?? []
return {
success: true,
output: {
name: data.name ?? null,
universalName: data.universal_name ?? null,
companyId: data.company_id ?? null,
description: data.description ?? null,
phone: data.phone ?? null,
linkedInUrl: data.url ?? null,
websiteUrl: data.website ?? null,
followers: data.followers ?? null,
staffCount: data.staffCount ?? null,
foundedDate: data.founded ?? null,
type: data.type ?? null,
industries: data.industries ?? [],
specialties: data.specialities ?? [],
headquarters: {
city: data.headquarter?.city ?? null,
country: data.headquarter?.country ?? null,
postalCode: data.headquarter?.postalCode ?? null,
line1: data.headquarter?.line1 ?? null,
},
logo: data.logo ?? null,
coverImage: data.cover ?? null,
fundingRounds,
},
}
},
outputs: {
name: {
type: 'string',
description: 'Company name',
optional: true,
},
universalName: {
type: 'string',
description: 'Universal company name',
optional: true,
},
companyId: {
type: 'string',
description: 'Company ID',
optional: true,
},
description: {
type: 'string',
description: 'Company description',
optional: true,
},
phone: {
type: 'string',
description: 'Phone number',
optional: true,
},
linkedInUrl: {
type: 'string',
description: 'LinkedIn company URL',
optional: true,
},
websiteUrl: {
type: 'string',
description: 'Company website',
optional: true,
},
followers: {
type: 'number',
description: 'Number of LinkedIn followers',
optional: true,
},
staffCount: {
type: 'number',
description: 'Number of employees',
optional: true,
},
foundedDate: {
type: 'string',
description: 'Date founded',
optional: true,
},
type: {
type: 'string',
description: 'Company type',
optional: true,
},
industries: {
type: 'array',
description: 'Industries',
items: {
type: 'string',
description: 'Industry',
},
},
specialties: {
type: 'array',
description: 'Company specialties',
items: {
type: 'string',
description: 'Specialty',
},
},
headquarters: {
type: 'json',
description: 'Headquarters location',
properties: {
city: { type: 'string', description: 'City' },
country: { type: 'string', description: 'Country' },
postalCode: { type: 'string', description: 'Postal code' },
line1: { type: 'string', description: 'Address line 1' },
},
},
logo: {
type: 'string',
description: 'Company logo URL',
optional: true,
},
coverImage: {
type: 'string',
description: 'Cover image URL',
optional: true,
},
fundingRounds: {
type: 'array',
description: 'Funding history',
items: {
type: 'object',
properties: {
roundType: { type: 'string', description: 'Funding round type' },
amount: { type: 'number', description: 'Amount raised' },
currency: { type: 'string', description: 'Currency' },
investors: { type: 'array', description: 'Investors' },
},
},
},
},
}

View File

@@ -1,215 +0,0 @@
import type { EnrichCompanyRevenueParams, EnrichCompanyRevenueResponse } from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const companyRevenueTool: ToolConfig<
EnrichCompanyRevenueParams,
EnrichCompanyRevenueResponse
> = {
id: 'enrich_company_revenue',
name: 'Enrich Company Revenue',
description:
'Retrieve company revenue data, CEO information, and competitive analysis by domain.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
domain: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Company domain (e.g., clay.io)',
},
},
request: {
url: (params) => {
const url = new URL('https://api.enrich.so/v1/api/company-revenue-plus')
url.searchParams.append('domain', params.domain.trim())
return url.toString()
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const competitors =
data.competitors?.map((comp: any) => ({
name: comp.name ?? '',
revenue: comp.revenue ?? null,
employeeCount: comp.employee_count ?? comp.employeeCount ?? null,
headquarters: comp.headquarters ?? null,
})) ?? []
// Handle socialLinks as array [{type, url}] or object {linkedIn, twitter, facebook}
const socialLinksArray = data.socialLinks ?? data.social_links
let socialLinks = {
linkedIn: null as string | null,
twitter: null as string | null,
facebook: null as string | null,
}
if (Array.isArray(socialLinksArray)) {
for (const link of socialLinksArray) {
const linkType = (link.type ?? '').toLowerCase()
if (linkType === 'linkedin') socialLinks.linkedIn = link.url ?? null
else if (linkType === 'twitter') socialLinks.twitter = link.url ?? null
else if (linkType === 'facebook') socialLinks.facebook = link.url ?? null
}
} else if (socialLinksArray && typeof socialLinksArray === 'object') {
socialLinks = {
linkedIn: socialLinksArray.linkedIn ?? socialLinksArray.linkedin ?? null,
twitter: socialLinksArray.twitter ?? null,
facebook: socialLinksArray.facebook ?? null,
}
}
// Handle fundingRounds as array or number
const fundingRoundsData = data.fundingRounds ?? data.funding_rounds
const fundingRoundsCount = Array.isArray(fundingRoundsData)
? fundingRoundsData.length
: fundingRoundsData
// Handle revenueDetails array for min/max
const revenueDetails = data.revenueDetails ?? data.revenue_details
let revenueMin = data.revenueMin ?? data.revenue_min ?? null
let revenueMax = data.revenueMax ?? data.revenue_max ?? null
if (Array.isArray(revenueDetails) && revenueDetails.length > 0) {
revenueMin = revenueDetails[0]?.rangeBegin ?? revenueDetails[0]?.range_begin ?? revenueMin
revenueMax = revenueDetails[0]?.rangeEnd ?? revenueDetails[0]?.range_end ?? revenueMax
}
return {
success: true,
output: {
companyName: data.companyName ?? data.company_name ?? null,
shortDescription: data.shortDescription ?? data.short_description ?? null,
fullSummary: data.fullSummary ?? data.full_summary ?? null,
revenue: data.revenue ?? null,
revenueMin,
revenueMax,
employeeCount: data.employeeCount ?? data.employee_count ?? null,
founded: data.founded ?? null,
ownership: data.ownership ?? null,
status: data.status ?? null,
website: data.website ?? null,
ceo: {
name: data.ceo?.fullName ?? data.ceo?.name ?? null,
designation: data.ceo?.designation ?? data.ceo?.title ?? null,
rating: data.ceo?.rating ?? null,
},
socialLinks,
totalFunding: data.totalFunding ?? data.total_funding ?? null,
fundingRounds: fundingRoundsCount ?? null,
competitors,
},
}
},
outputs: {
companyName: {
type: 'string',
description: 'Company name',
optional: true,
},
shortDescription: {
type: 'string',
description: 'Short company description',
optional: true,
},
fullSummary: {
type: 'string',
description: 'Full company summary',
optional: true,
},
revenue: {
type: 'string',
description: 'Company revenue',
optional: true,
},
revenueMin: {
type: 'number',
description: 'Minimum revenue estimate',
optional: true,
},
revenueMax: {
type: 'number',
description: 'Maximum revenue estimate',
optional: true,
},
employeeCount: {
type: 'number',
description: 'Number of employees',
optional: true,
},
founded: {
type: 'string',
description: 'Year founded',
optional: true,
},
ownership: {
type: 'string',
description: 'Ownership type',
optional: true,
},
status: {
type: 'string',
description: 'Company status (e.g., Active)',
optional: true,
},
website: {
type: 'string',
description: 'Company website URL',
optional: true,
},
ceo: {
type: 'json',
description: 'CEO information',
properties: {
name: { type: 'string', description: 'CEO name' },
designation: { type: 'string', description: 'CEO designation/title' },
rating: { type: 'number', description: 'CEO rating' },
},
},
socialLinks: {
type: 'json',
description: 'Social media links',
properties: {
linkedIn: { type: 'string', description: 'LinkedIn URL' },
twitter: { type: 'string', description: 'Twitter URL' },
facebook: { type: 'string', description: 'Facebook URL' },
},
},
totalFunding: {
type: 'string',
description: 'Total funding raised',
optional: true,
},
fundingRounds: {
type: 'number',
description: 'Number of funding rounds',
optional: true,
},
competitors: {
type: 'array',
description: 'Competitors',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Competitor name' },
revenue: { type: 'string', description: 'Revenue' },
employeeCount: { type: 'number', description: 'Employee count' },
headquarters: { type: 'string', description: 'Headquarters' },
},
},
},
},
}

View File

@@ -1,102 +0,0 @@
import type {
EnrichDisposableEmailCheckParams,
EnrichDisposableEmailCheckResponse,
} from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const disposableEmailCheckTool: ToolConfig<
EnrichDisposableEmailCheckParams,
EnrichDisposableEmailCheckResponse
> = {
id: 'enrich_disposable_email_check',
name: 'Enrich Disposable Email Check',
description:
'Check if an email address is from a disposable or temporary email provider. Returns a score and validation details.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
email: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Email address to check (e.g., john.doe@example.com)',
},
},
request: {
url: (params) => {
const url = new URL('https://api.enrich.so/v1/api/disposable-email-check')
url.searchParams.append('email', params.email.trim())
return url.toString()
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const emailData = data.data ?? {}
return {
success: true,
output: {
email: emailData.email ?? '',
score: emailData.score ?? 0,
testsPassed: emailData.tests_passed ?? '0/0',
passed: emailData.passed ?? false,
reason: emailData.reason ?? null,
mailServerIp: emailData.mail_server_ip ?? null,
mxRecords: emailData.mx_records ?? [],
},
}
},
outputs: {
email: {
type: 'string',
description: 'Email address checked',
},
score: {
type: 'number',
description: 'Validation score (0-100)',
},
testsPassed: {
type: 'string',
description: 'Number of tests passed (e.g., "3/3")',
},
passed: {
type: 'boolean',
description: 'Whether the email passed all validation tests',
},
reason: {
type: 'string',
description: 'Reason for failure if email did not pass',
optional: true,
},
mailServerIp: {
type: 'string',
description: 'Mail server IP address',
optional: true,
},
mxRecords: {
type: 'array',
description: 'MX records for the domain',
items: {
type: 'object',
properties: {
host: { type: 'string', description: 'MX record host' },
pref: { type: 'number', description: 'MX record preference' },
},
},
},
},
}

View File

@@ -1,67 +0,0 @@
import type { EnrichEmailToIpParams, EnrichEmailToIpResponse } from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const emailToIpTool: ToolConfig<EnrichEmailToIpParams, EnrichEmailToIpResponse> = {
id: 'enrich_email_to_ip',
name: 'Enrich Email to IP',
description: 'Discover an IP address associated with an email address.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
email: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Email address to look up (e.g., john.doe@example.com)',
},
},
request: {
url: (params) => {
const url = new URL('https://api.enrich.so/v1/api/email-to-ip')
url.searchParams.append('email', params.email.trim())
return url.toString()
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const ipData = data.data ?? {}
return {
success: true,
output: {
email: ipData.email ?? '',
ip: ipData.ip ?? null,
found: !!ipData.ip,
},
}
},
outputs: {
email: {
type: 'string',
description: 'Email address looked up',
},
ip: {
type: 'string',
description: 'Associated IP address',
optional: true,
},
found: {
type: 'boolean',
description: 'Whether an IP address was found',
},
},
}

View File

@@ -1,177 +0,0 @@
import type {
EnrichEmailToPersonLiteParams,
EnrichEmailToPersonLiteResponse,
} from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const emailToPersonLiteTool: ToolConfig<
EnrichEmailToPersonLiteParams,
EnrichEmailToPersonLiteResponse
> = {
id: 'enrich_email_to_person_lite',
name: 'Enrich Email to Person Lite',
description:
'Retrieve basic LinkedIn profile information from an email address. A lighter version with essential data only.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
email: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Email address to look up (e.g., john.doe@company.com)',
},
},
request: {
url: (params) => {
const url = new URL('https://api.enrich.so/v1/api/email-to-linkedin-lite')
url.searchParams.append('email', params.email.trim())
return url.toString()
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
name: data.name ?? null,
firstName: data.first_name ?? data.firstName ?? null,
lastName: data.last_name ?? data.lastName ?? null,
email: data.email ?? null,
title: data.title ?? null,
location: data.location ?? null,
company: data.company ?? null,
companyLocation: data.company_location ?? data.companyLocation ?? null,
companyLinkedIn: data.company_linkedin ?? data.companyLinkedIn ?? null,
profileId: data.profile_id ?? data.profileId ?? null,
schoolName: data.school_name ?? data.schoolName ?? null,
schoolUrl: data.school_url ?? data.schoolUrl ?? null,
linkedInUrl: data.linkedin_url ?? data.linkedInUrl ?? null,
photoUrl: data.photo_url ?? data.photoUrl ?? null,
followerCount: data.follower_count ?? data.followerCount ?? null,
connectionCount: data.connection_count ?? data.connectionCount ?? null,
languages: data.languages ?? [],
projects: data.projects ?? [],
certifications: data.certifications ?? [],
volunteerExperience: data.volunteer_experience ?? data.volunteerExperience ?? [],
},
}
},
outputs: {
name: {
type: 'string',
description: 'Full name',
optional: true,
},
firstName: {
type: 'string',
description: 'First name',
optional: true,
},
lastName: {
type: 'string',
description: 'Last name',
optional: true,
},
email: {
type: 'string',
description: 'Email address',
optional: true,
},
title: {
type: 'string',
description: 'Job title',
optional: true,
},
location: {
type: 'string',
description: 'Location',
optional: true,
},
company: {
type: 'string',
description: 'Current company',
optional: true,
},
companyLocation: {
type: 'string',
description: 'Company location',
optional: true,
},
companyLinkedIn: {
type: 'string',
description: 'Company LinkedIn URL',
optional: true,
},
profileId: {
type: 'string',
description: 'LinkedIn profile ID',
optional: true,
},
schoolName: {
type: 'string',
description: 'School name',
optional: true,
},
schoolUrl: {
type: 'string',
description: 'School URL',
optional: true,
},
linkedInUrl: {
type: 'string',
description: 'LinkedIn profile URL',
optional: true,
},
photoUrl: {
type: 'string',
description: 'Profile photo URL',
optional: true,
},
followerCount: {
type: 'number',
description: 'Number of followers',
optional: true,
},
connectionCount: {
type: 'number',
description: 'Number of connections',
optional: true,
},
languages: {
type: 'array',
description: 'Languages spoken',
items: { type: 'string', description: 'Language' },
},
projects: {
type: 'array',
description: 'Projects',
items: { type: 'string', description: 'Project' },
},
certifications: {
type: 'array',
description: 'Certifications',
items: { type: 'string', description: 'Certification' },
},
volunteerExperience: {
type: 'array',
description: 'Volunteer experience',
items: { type: 'string', description: 'Volunteer role' },
},
},
}

View File

@@ -1,86 +0,0 @@
import type { EnrichEmailToPhoneParams, EnrichEmailToPhoneResponse } from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const emailToPhoneTool: ToolConfig<EnrichEmailToPhoneParams, EnrichEmailToPhoneResponse> = {
id: 'enrich_email_to_phone',
name: 'Enrich Email to Phone',
description: 'Find a phone number associated with an email address.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
email: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Email address to look up (e.g., john.doe@example.com)',
},
},
request: {
url: (params) => {
const url = new URL('https://api.enrich.so/v1/api/email-to-mobile')
url.searchParams.append('email', params.email.trim())
return url.toString()
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
// Handle queued response (202)
if (data.message?.includes('queued')) {
return {
success: true,
output: {
email: null,
mobileNumber: null,
found: false,
status: 'in_progress',
},
}
}
return {
success: true,
output: {
email: data.data?.email ?? null,
mobileNumber: data.data?.mobile_number ?? null,
found: !!data.data?.mobile_number,
status: 'completed',
},
}
},
outputs: {
email: {
type: 'string',
description: 'Email address looked up',
optional: true,
},
mobileNumber: {
type: 'string',
description: 'Found mobile phone number',
optional: true,
},
found: {
type: 'boolean',
description: 'Whether a phone number was found',
},
status: {
type: 'string',
description: 'Request status (in_progress or completed)',
optional: true,
},
},
}

View File

@@ -1,239 +0,0 @@
import type { EnrichEmailToProfileParams, EnrichEmailToProfileResponse } from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const emailToProfileTool: ToolConfig<
EnrichEmailToProfileParams,
EnrichEmailToProfileResponse
> = {
id: 'enrich_email_to_profile',
name: 'Enrich Email to Profile',
description:
'Retrieve detailed LinkedIn profile information using an email address including work history, education, and skills.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
email: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Email address to look up (e.g., john.doe@company.com)',
},
inRealtime: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Set to true to retrieve fresh data, bypassing cached information',
},
},
request: {
url: (params) => {
const url = new URL('https://api.enrich.so/v1/api/person')
url.searchParams.append('email', params.email.trim())
if (params.inRealtime !== undefined) {
url.searchParams.append('in_realtime', String(params.inRealtime))
}
return url.toString()
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
// API returns positions nested under data.positions.positionHistory
const positionHistory =
data.positions?.positionHistory?.map((pos: any) => ({
title: pos.title ?? '',
company: pos.company?.companyName ?? '',
startDate: pos.startEndDate?.start
? `${pos.startEndDate.start.year}-${pos.startEndDate.start.month ?? 1}`
: null,
endDate: pos.startEndDate?.end
? `${pos.startEndDate.end.year}-${pos.startEndDate.end.month ?? 1}`
: null,
location: pos.company?.companyLocation ?? null,
})) ?? []
// API returns education nested under data.schools.educationHistory
const education =
data.schools?.educationHistory?.map((edu: any) => ({
school: edu.school?.schoolName ?? '',
degree: edu.degreeName ?? null,
fieldOfStudy: edu.fieldOfStudy ?? null,
startDate: edu.startEndDate?.start?.year ? String(edu.startEndDate.start.year) : null,
endDate: edu.startEndDate?.end?.year ? String(edu.startEndDate.end.year) : null,
})) ?? []
const certifications =
data.certifications?.map((cert: any) => ({
name: cert.name ?? '',
authority: cert.authority ?? null,
url: cert.url ?? null,
})) ?? []
return {
success: true,
output: {
displayName: data.displayName ?? null,
firstName: data.firstName ?? null,
lastName: data.lastName ?? null,
headline: data.headline ?? null,
occupation: data.occupation ?? null,
summary: data.summary ?? null,
location: data.location ?? null,
country: data.country ?? null,
linkedInUrl: data.linkedInUrl ?? null,
photoUrl: data.photoUrl ?? null,
connectionCount: data.connectionCount ?? null,
isConnectionCountObfuscated: data.isConnectionCountObfuscated ?? null,
positionHistory,
education,
certifications,
skills: data.skills ?? [],
languages: data.languages ?? [],
locale: data.locale ?? null,
version: data.version ?? null,
},
}
},
outputs: {
displayName: {
type: 'string',
description: 'Full display name',
optional: true,
},
firstName: {
type: 'string',
description: 'First name',
optional: true,
},
lastName: {
type: 'string',
description: 'Last name',
optional: true,
},
headline: {
type: 'string',
description: 'Professional headline',
optional: true,
},
occupation: {
type: 'string',
description: 'Current occupation',
optional: true,
},
summary: {
type: 'string',
description: 'Profile summary',
optional: true,
},
location: {
type: 'string',
description: 'Location',
optional: true,
},
country: {
type: 'string',
description: 'Country',
optional: true,
},
linkedInUrl: {
type: 'string',
description: 'LinkedIn profile URL',
optional: true,
},
photoUrl: {
type: 'string',
description: 'Profile photo URL',
optional: true,
},
connectionCount: {
type: 'number',
description: 'Number of connections',
optional: true,
},
isConnectionCountObfuscated: {
type: 'boolean',
description: 'Whether connection count is obfuscated (500+)',
optional: true,
},
positionHistory: {
type: 'array',
description: 'Work experience history',
items: {
type: 'object',
properties: {
title: { type: 'string', description: 'Job title' },
company: { type: 'string', description: 'Company name' },
startDate: { type: 'string', description: 'Start date' },
endDate: { type: 'string', description: 'End date' },
location: { type: 'string', description: 'Location' },
},
},
},
education: {
type: 'array',
description: 'Education history',
items: {
type: 'object',
properties: {
school: { type: 'string', description: 'School name' },
degree: { type: 'string', description: 'Degree' },
fieldOfStudy: { type: 'string', description: 'Field of study' },
startDate: { type: 'string', description: 'Start date' },
endDate: { type: 'string', description: 'End date' },
},
},
},
certifications: {
type: 'array',
description: 'Professional certifications',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Certification name' },
authority: { type: 'string', description: 'Issuing authority' },
url: { type: 'string', description: 'Certification URL' },
},
},
},
skills: {
type: 'array',
description: 'List of skills',
items: {
type: 'string',
description: 'Skill',
},
},
languages: {
type: 'array',
description: 'List of languages',
items: {
type: 'string',
description: 'Language',
},
},
locale: {
type: 'string',
description: 'Profile locale (e.g., en_US)',
optional: true,
},
version: {
type: 'number',
description: 'Profile version number',
optional: true,
},
},
}

View File

@@ -1,107 +0,0 @@
import type { EnrichFindEmailParams, EnrichFindEmailResponse } from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const findEmailTool: ToolConfig<EnrichFindEmailParams, EnrichFindEmailResponse> = {
id: 'enrich_find_email',
name: 'Enrich Find Email',
description: "Find a person's work email address using their full name and company domain.",
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
fullName: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: "Person's full name (e.g., John Doe)",
},
companyDomain: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Company domain (e.g., example.com)',
},
},
request: {
url: (params) => {
const url = new URL('https://api.enrich.so/v1/api/find-email')
url.searchParams.append('fullName', params.fullName.trim())
url.searchParams.append('companyDomain', params.companyDomain.trim())
return url.toString()
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
// Handle queued response (202)
if (data.status === 'in_progress' || data.message?.includes('queued')) {
return {
success: true,
output: {
email: null,
firstName: null,
lastName: null,
domain: null,
found: false,
acceptAll: null,
},
}
}
return {
success: true,
output: {
email: data.email ?? null,
firstName: data.firstName ?? null,
lastName: data.lastName ?? null,
domain: data.domain ?? null,
found: data.found ?? false,
acceptAll: data.acceptAll ?? null,
},
}
},
outputs: {
email: {
type: 'string',
description: 'Found email address',
optional: true,
},
firstName: {
type: 'string',
description: 'First name',
optional: true,
},
lastName: {
type: 'string',
description: 'Last name',
optional: true,
},
domain: {
type: 'string',
description: 'Company domain',
optional: true,
},
found: {
type: 'boolean',
description: 'Whether an email was found',
},
acceptAll: {
type: 'boolean',
description: 'Whether the domain accepts all emails',
optional: true,
},
},
}

View File

@@ -1,116 +0,0 @@
import type { EnrichGetPostDetailsParams, EnrichGetPostDetailsResponse } from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const getPostDetailsTool: ToolConfig<
EnrichGetPostDetailsParams,
EnrichGetPostDetailsResponse
> = {
id: 'enrich_get_post_details',
name: 'Enrich Get Post Details',
description: 'Get detailed information about a LinkedIn post by URL.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
url: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'LinkedIn post URL',
},
},
request: {
url: (params) => {
const url = new URL('https://api.enrich.so/v1/api/post-details')
url.searchParams.append('url', params.url.trim())
return url.toString()
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
Accept: 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
postId: data.PostId ?? null,
author: {
name: data.author?.name ?? null,
headline: data.author?.headline ?? null,
linkedInUrl: data.author?.linkedin_url ?? null,
profileImage: data.author?.profile_image ?? null,
},
timestamp: data.post?.timestamp ?? null,
textContent: data.post?.text_content ?? null,
hashtags: data.post?.hashtags ?? [],
mediaUrls: data.post?.post_media_url ?? [],
reactions: data.engagement?.reactions ?? 0,
commentsCount: data.engagement?.comments_count ?? 0,
},
}
},
outputs: {
postId: {
type: 'string',
description: 'Post ID',
optional: true,
},
author: {
type: 'json',
description: 'Author information',
properties: {
name: { type: 'string', description: 'Author name' },
headline: { type: 'string', description: 'Author headline' },
linkedInUrl: { type: 'string', description: 'Author LinkedIn URL' },
profileImage: { type: 'string', description: 'Author profile image' },
},
},
timestamp: {
type: 'string',
description: 'Post timestamp',
optional: true,
},
textContent: {
type: 'string',
description: 'Post text content',
optional: true,
},
hashtags: {
type: 'array',
description: 'Hashtags',
items: {
type: 'string',
description: 'Hashtag',
},
},
mediaUrls: {
type: 'array',
description: 'Media URLs',
items: {
type: 'string',
description: 'Media URL',
},
},
reactions: {
type: 'number',
description: 'Number of reactions',
},
commentsCount: {
type: 'number',
description: 'Number of comments',
},
},
}

View File

@@ -1,59 +0,0 @@
import { checkCreditsTool } from '@/tools/enrich/check_credits'
import { companyFundingTool } from '@/tools/enrich/company_funding'
import { companyLookupTool } from '@/tools/enrich/company_lookup'
import { companyRevenueTool } from '@/tools/enrich/company_revenue'
import { disposableEmailCheckTool } from '@/tools/enrich/disposable_email_check'
import { emailToIpTool } from '@/tools/enrich/email_to_ip'
import { emailToPersonLiteTool } from '@/tools/enrich/email_to_person_lite'
import { emailToPhoneTool } from '@/tools/enrich/email_to_phone'
import { emailToProfileTool } from '@/tools/enrich/email_to_profile'
import { findEmailTool } from '@/tools/enrich/find_email'
import { getPostDetailsTool } from '@/tools/enrich/get_post_details'
import { ipToCompanyTool } from '@/tools/enrich/ip_to_company'
import { linkedInProfileTool } from '@/tools/enrich/linkedin_profile'
import { linkedInToPersonalEmailTool } from '@/tools/enrich/linkedin_to_personal_email'
import { linkedInToWorkEmailTool } from '@/tools/enrich/linkedin_to_work_email'
import { phoneFinderTool } from '@/tools/enrich/phone_finder'
import { reverseHashLookupTool } from '@/tools/enrich/reverse_hash_lookup'
import { salesPointerPeopleTool } from '@/tools/enrich/sales_pointer_people'
import { searchCompanyTool } from '@/tools/enrich/search_company'
import { searchCompanyActivitiesTool } from '@/tools/enrich/search_company_activities'
import { searchCompanyEmployeesTool } from '@/tools/enrich/search_company_employees'
import { searchLogoTool } from '@/tools/enrich/search_logo'
import { searchPeopleTool } from '@/tools/enrich/search_people'
import { searchPeopleActivitiesTool } from '@/tools/enrich/search_people_activities'
import { searchPostCommentsTool } from '@/tools/enrich/search_post_comments'
import { searchPostReactionsTool } from '@/tools/enrich/search_post_reactions'
import { searchPostsTool } from '@/tools/enrich/search_posts'
import { searchSimilarCompaniesTool } from '@/tools/enrich/search_similar_companies'
import { verifyEmailTool } from '@/tools/enrich/verify_email'
export const enrichCheckCreditsTool = checkCreditsTool
export const enrichEmailToProfileTool = emailToProfileTool
export const enrichEmailToPersonLiteTool = emailToPersonLiteTool
export const enrichLinkedInProfileTool = linkedInProfileTool
export const enrichFindEmailTool = findEmailTool
export const enrichLinkedInToWorkEmailTool = linkedInToWorkEmailTool
export const enrichLinkedInToPersonalEmailTool = linkedInToPersonalEmailTool
export const enrichPhoneFinderTool = phoneFinderTool
export const enrichEmailToPhoneTool = emailToPhoneTool
export const enrichVerifyEmailTool = verifyEmailTool
export const enrichDisposableEmailCheckTool = disposableEmailCheckTool
export const enrichEmailToIpTool = emailToIpTool
export const enrichIpToCompanyTool = ipToCompanyTool
export const enrichCompanyLookupTool = companyLookupTool
export const enrichCompanyFundingTool = companyFundingTool
export const enrichCompanyRevenueTool = companyRevenueTool
export const enrichSearchPeopleTool = searchPeopleTool
export const enrichSearchCompanyTool = searchCompanyTool
export const enrichSearchCompanyEmployeesTool = searchCompanyEmployeesTool
export const enrichSearchSimilarCompaniesTool = searchSimilarCompaniesTool
export const enrichSalesPointerPeopleTool = salesPointerPeopleTool
export const enrichSearchPostsTool = searchPostsTool
export const enrichGetPostDetailsTool = getPostDetailsTool
export const enrichSearchPostReactionsTool = searchPostReactionsTool
export const enrichSearchPostCommentsTool = searchPostCommentsTool
export const enrichSearchPeopleActivitiesTool = searchPeopleActivitiesTool
export const enrichSearchCompanyActivitiesTool = searchCompanyActivitiesTool
export const enrichReverseHashLookupTool = reverseHashLookupTool
export const enrichSearchLogoTool = searchLogoTool

View File

@@ -1,148 +0,0 @@
import type { EnrichIpToCompanyParams, EnrichIpToCompanyResponse } from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const ipToCompanyTool: ToolConfig<EnrichIpToCompanyParams, EnrichIpToCompanyResponse> = {
id: 'enrich_ip_to_company',
name: 'Enrich IP to Company',
description: 'Identify a company from an IP address with detailed firmographic information.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
ip: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'IP address to look up (e.g., 86.92.60.221)',
},
},
request: {
url: (params) => {
const url = new URL('https://api.enrich.so/v1/api/ip-to-company-lookup')
url.searchParams.append('ip', params.ip.trim())
return url.toString()
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const companyData = data.data ?? {}
return {
success: true,
output: {
name: companyData.name ?? null,
legalName: companyData.legalName ?? null,
domain: companyData.domain ?? null,
domainAliases: companyData.domainAliases ?? [],
sector: companyData.sector ?? null,
industry: companyData.industry ?? null,
phone: companyData.phone ?? null,
employees: companyData.employees ?? null,
revenue: companyData.revenue ?? null,
location: {
city: companyData.geo?.city ?? null,
state: companyData.geo?.state ?? null,
country: companyData.geo?.country ?? null,
timezone: companyData.timezone ?? null,
},
linkedInUrl: companyData.linkedin?.handle
? `https://linkedin.com/company/${companyData.linkedin.handle}`
: null,
twitterUrl: companyData.twitter?.handle
? `https://twitter.com/${companyData.twitter.handle}`
: null,
facebookUrl: companyData.facebook?.handle
? `https://facebook.com/${companyData.facebook.handle}`
: null,
},
}
},
outputs: {
name: {
type: 'string',
description: 'Company name',
optional: true,
},
legalName: {
type: 'string',
description: 'Legal company name',
optional: true,
},
domain: {
type: 'string',
description: 'Primary domain',
optional: true,
},
domainAliases: {
type: 'array',
description: 'Domain aliases',
items: {
type: 'string',
description: 'Domain alias',
},
},
sector: {
type: 'string',
description: 'Business sector',
optional: true,
},
industry: {
type: 'string',
description: 'Industry',
optional: true,
},
phone: {
type: 'string',
description: 'Phone number',
optional: true,
},
employees: {
type: 'number',
description: 'Number of employees',
optional: true,
},
revenue: {
type: 'string',
description: 'Estimated revenue',
optional: true,
},
location: {
type: 'json',
description: 'Company location',
properties: {
city: { type: 'string', description: 'City' },
state: { type: 'string', description: 'State' },
country: { type: 'string', description: 'Country' },
timezone: { type: 'string', description: 'Timezone' },
},
},
linkedInUrl: {
type: 'string',
description: 'LinkedIn company URL',
optional: true,
},
twitterUrl: {
type: 'string',
description: 'Twitter URL',
optional: true,
},
facebookUrl: {
type: 'string',
description: 'Facebook URL',
optional: true,
},
},
}

View File

@@ -1,190 +0,0 @@
import type {
EnrichLinkedInProfileParams,
EnrichLinkedInProfileResponse,
} from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const linkedInProfileTool: ToolConfig<
EnrichLinkedInProfileParams,
EnrichLinkedInProfileResponse
> = {
id: 'enrich_linkedin_profile',
name: 'Enrich LinkedIn Profile',
description:
'Enrich a LinkedIn profile URL with detailed information including positions, education, and social metrics.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
url: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'LinkedIn profile URL (e.g., linkedin.com/in/williamhgates)',
},
},
request: {
url: (params) => {
const url = new URL('https://api.enrich.so/v1/api/linkedin-by-url')
url.searchParams.append('url', params.url.trim())
url.searchParams.append('type', 'person')
return url.toString()
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const positions =
data.position_groups?.flatMap(
(group: any) =>
group.profile_positions?.map((pos: any) => ({
title: pos.title ?? '',
company: group.company?.name ?? pos.company ?? '',
companyLogo: group.company?.logo ?? null,
startDate: pos.start_date ?? null,
endDate: pos.end_date ?? null,
location: pos.location ?? null,
})) ?? []
) ?? []
const education =
data.education?.map((edu: any) => ({
school: edu.school?.name ?? edu.school_name ?? '',
degree: edu.degree_name ?? edu.degree ?? null,
fieldOfStudy: edu.field_of_study ?? null,
startDate: edu.start_date ?? null,
endDate: edu.end_date ?? null,
})) ?? []
return {
success: true,
output: {
profileId: data.profile_id ?? null,
firstName: data.first_name ?? null,
lastName: data.last_name ?? null,
subTitle: data.sub_title ?? null,
profilePicture: data.profile_picture ?? null,
backgroundImage: data.background_image ?? null,
industry: data.industry ?? null,
location: data.location?.default ?? data.location ?? null,
followersCount: data.followers_count ?? null,
connectionsCount: data.connections_count ?? null,
premium: data.premium ?? false,
influencer: data.influencer ?? false,
positions,
education,
websites: data.websites ?? [],
},
}
},
outputs: {
profileId: {
type: 'string',
description: 'LinkedIn profile ID',
optional: true,
},
firstName: {
type: 'string',
description: 'First name',
optional: true,
},
lastName: {
type: 'string',
description: 'Last name',
optional: true,
},
subTitle: {
type: 'string',
description: 'Profile subtitle/headline',
optional: true,
},
profilePicture: {
type: 'string',
description: 'Profile picture URL',
optional: true,
},
backgroundImage: {
type: 'string',
description: 'Background image URL',
optional: true,
},
industry: {
type: 'string',
description: 'Industry',
optional: true,
},
location: {
type: 'string',
description: 'Location',
optional: true,
},
followersCount: {
type: 'number',
description: 'Number of followers',
optional: true,
},
connectionsCount: {
type: 'number',
description: 'Number of connections',
optional: true,
},
premium: {
type: 'boolean',
description: 'Whether the account is premium',
},
influencer: {
type: 'boolean',
description: 'Whether the account is an influencer',
},
positions: {
type: 'array',
description: 'Work positions',
items: {
type: 'object',
properties: {
title: { type: 'string', description: 'Job title' },
company: { type: 'string', description: 'Company name' },
companyLogo: { type: 'string', description: 'Company logo URL' },
startDate: { type: 'string', description: 'Start date' },
endDate: { type: 'string', description: 'End date' },
location: { type: 'string', description: 'Location' },
},
},
},
education: {
type: 'array',
description: 'Education history',
items: {
type: 'object',
properties: {
school: { type: 'string', description: 'School name' },
degree: { type: 'string', description: 'Degree' },
fieldOfStudy: { type: 'string', description: 'Field of study' },
startDate: { type: 'string', description: 'Start date' },
endDate: { type: 'string', description: 'End date' },
},
},
},
websites: {
type: 'array',
description: 'Personal websites',
items: {
type: 'string',
description: 'Website URL',
},
},
},
}

View File

@@ -1,75 +0,0 @@
import type {
EnrichLinkedInToPersonalEmailParams,
EnrichLinkedInToPersonalEmailResponse,
} from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const linkedInToPersonalEmailTool: ToolConfig<
EnrichLinkedInToPersonalEmailParams,
EnrichLinkedInToPersonalEmailResponse
> = {
id: 'enrich_linkedin_to_personal_email',
name: 'Enrich LinkedIn to Personal Email',
description: 'Find personal email address from a LinkedIn profile URL.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
linkedinProfile: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'LinkedIn profile URL (e.g., linkedin.com/in/username)',
},
},
request: {
url: (params) => {
const url = new URL('https://api.enrich.so/v2/api/linkedin-to-email')
url.searchParams.append('linkedin_profile', params.linkedinProfile.trim())
return url.toString()
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
accept: 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const resultData = data.data ?? data
return {
success: true,
output: {
email: resultData.email ?? resultData.personal_email ?? null,
found: resultData.found ?? Boolean(resultData.email ?? resultData.personal_email),
status: resultData.status ?? null,
},
}
},
outputs: {
email: {
type: 'string',
description: 'Personal email address',
optional: true,
},
found: {
type: 'boolean',
description: 'Whether an email was found',
},
status: {
type: 'string',
description: 'Request status',
optional: true,
},
},
}

View File

@@ -1,85 +0,0 @@
import type {
EnrichLinkedInToWorkEmailParams,
EnrichLinkedInToWorkEmailResponse,
} from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const linkedInToWorkEmailTool: ToolConfig<
EnrichLinkedInToWorkEmailParams,
EnrichLinkedInToWorkEmailResponse
> = {
id: 'enrich_linkedin_to_work_email',
name: 'Enrich LinkedIn to Work Email',
description: 'Find a work email address from a LinkedIn profile URL.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
linkedinProfile: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'LinkedIn profile URL (e.g., https://www.linkedin.com/in/williamhgates)',
},
},
request: {
url: (params) => {
const url = new URL('https://api.enrich.so/v2/api/linkedin-to-email')
url.searchParams.append('linkedin_profile', params.linkedinProfile.trim())
return url.toString()
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
// Handle queued response (202)
if (data.status === 'in_progress' || data.message?.includes('queued')) {
return {
success: true,
output: {
email: null,
found: false,
status: 'in_progress',
},
}
}
return {
success: true,
output: {
email: data.email ?? null,
found: data.found ?? false,
status: 'completed',
},
}
},
outputs: {
email: {
type: 'string',
description: 'Found work email address',
optional: true,
},
found: {
type: 'boolean',
description: 'Whether an email was found',
},
status: {
type: 'string',
description: 'Request status (in_progress or completed)',
optional: true,
},
},
}

View File

@@ -1,86 +0,0 @@
import type { EnrichPhoneFinderParams, EnrichPhoneFinderResponse } from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const phoneFinderTool: ToolConfig<EnrichPhoneFinderParams, EnrichPhoneFinderResponse> = {
id: 'enrich_phone_finder',
name: 'Enrich Phone Finder',
description: 'Find a phone number from a LinkedIn profile URL.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
linkedinProfile: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'LinkedIn profile URL (e.g., linkedin.com/in/williamhgates)',
},
},
request: {
url: (params) => {
const url = new URL('https://api.enrich.so/v1/api/mobile-finder')
url.searchParams.append('linkedin_profile', params.linkedinProfile.trim())
return url.toString()
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
// Handle queued response (202)
if (data.message?.includes('queued')) {
return {
success: true,
output: {
profileUrl: null,
mobileNumber: null,
found: false,
status: 'in_progress',
},
}
}
return {
success: true,
output: {
profileUrl: data.data?.profile_url ?? null,
mobileNumber: data.data?.mobile_number ?? null,
found: !!data.data?.mobile_number,
status: 'completed',
},
}
},
outputs: {
profileUrl: {
type: 'string',
description: 'LinkedIn profile URL',
optional: true,
},
mobileNumber: {
type: 'string',
description: 'Found mobile phone number',
optional: true,
},
found: {
type: 'boolean',
description: 'Whether a phone number was found',
},
status: {
type: 'string',
description: 'Request status (in_progress or completed)',
optional: true,
},
},
}

View File

@@ -1,79 +0,0 @@
import type {
EnrichReverseHashLookupParams,
EnrichReverseHashLookupResponse,
} from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const reverseHashLookupTool: ToolConfig<
EnrichReverseHashLookupParams,
EnrichReverseHashLookupResponse
> = {
id: 'enrich_reverse_hash_lookup',
name: 'Enrich Reverse Hash Lookup',
description: 'Convert an MD5 email hash back to the original email address and display name.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
hash: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'MD5 hash value to look up',
},
},
request: {
url: (params) => {
const url = new URL('https://api.enrich.so/v1/api/reverse-hash-lookup')
url.searchParams.append('hash', params.hash.trim())
return url.toString()
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const resultData = data.data ?? {}
return {
success: true,
output: {
hash: resultData.hash ?? '',
email: resultData.email ?? null,
displayName: resultData.display_name ?? null,
found: !!resultData.email,
},
}
},
outputs: {
hash: {
type: 'string',
description: 'MD5 hash that was looked up',
},
email: {
type: 'string',
description: 'Original email address',
optional: true,
},
displayName: {
type: 'string',
description: 'Display name associated with the email',
optional: true,
},
found: {
type: 'boolean',
description: 'Whether an email was found for the hash',
},
},
}

View File

@@ -1,133 +0,0 @@
import type {
EnrichSalesPointerPeopleParams,
EnrichSalesPointerPeopleResponse,
} from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const salesPointerPeopleTool: ToolConfig<
EnrichSalesPointerPeopleParams,
EnrichSalesPointerPeopleResponse
> = {
id: 'enrich_sales_pointer_people',
name: 'Enrich Sales Pointer People',
description:
'Advanced people search with complex filters for location, company size, seniority, experience, and more.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
page: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'Page number (starts at 1)',
},
filters: {
type: 'json',
required: true,
visibility: 'user-or-llm',
description:
'Array of filter objects. Each filter has type (e.g., POSTAL_CODE, COMPANY_HEADCOUNT), values (array with id, text, selectionType: INCLUDED/EXCLUDED), and optional selectedSubFilter',
},
},
request: {
url: 'https://api.enrich.so/v1/api/sales-pointer/people',
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params) => ({
page: params.page,
filters: params.filters,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const resultData = data.data ?? {}
const profiles =
resultData.data?.map((person: any) => ({
name:
person.fullName ??
person.name ??
(person.firstName && person.lastName ? `${person.firstName} ${person.lastName}` : null),
summary: person.summary ?? person.headline ?? null,
location: person.location ?? person.geoRegion ?? null,
profilePicture:
person.profilePicture ?? person.profile_picture ?? person.profilePictureUrl ?? null,
linkedInUrn: person.linkedInUrn ?? person.linkedin_urn ?? person.urn ?? null,
positions: (person.positions ?? person.experience ?? []).map((pos: any) => ({
title: pos.title ?? '',
company: pos.companyName ?? pos.company ?? '',
})),
education: (person.education ?? []).map((edu: any) => ({
school: edu.schoolName ?? edu.school ?? '',
degree: edu.degreeName ?? edu.degree ?? null,
})),
})) ?? []
return {
success: true,
output: {
data: profiles,
pagination: {
totalCount: resultData.pagination?.total ?? 0,
returnedCount: resultData.pagination?.returned ?? 0,
start: resultData.pagination?.start ?? 0,
limit: resultData.pagination?.limit ?? 0,
},
},
}
},
outputs: {
data: {
type: 'array',
description: 'People results',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Full name' },
summary: { type: 'string', description: 'Professional summary' },
location: { type: 'string', description: 'Location' },
profilePicture: { type: 'string', description: 'Profile picture URL' },
linkedInUrn: { type: 'string', description: 'LinkedIn URN' },
positions: {
type: 'array',
description: 'Work positions',
properties: {
title: { type: 'string', description: 'Job title' },
company: { type: 'string', description: 'Company' },
},
},
education: {
type: 'array',
description: 'Education',
properties: {
school: { type: 'string', description: 'School' },
degree: { type: 'string', description: 'Degree' },
},
},
},
},
},
pagination: {
type: 'json',
description: 'Pagination info',
properties: {
totalCount: { type: 'number', description: 'Total results' },
returnedCount: { type: 'number', description: 'Returned count' },
start: { type: 'number', description: 'Start position' },
limit: { type: 'number', description: 'Limit' },
},
},
},
}

View File

@@ -1,216 +0,0 @@
import type { EnrichSearchCompanyParams, EnrichSearchCompanyResponse } from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const searchCompanyTool: ToolConfig<EnrichSearchCompanyParams, EnrichSearchCompanyResponse> =
{
id: 'enrich_search_company',
name: 'Enrich Search Company',
description:
'Search for companies by various criteria including name, industry, location, and size.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
name: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Company name',
},
website: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Company website URL',
},
tagline: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Company tagline',
},
type: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Company type (e.g., Private, Public)',
},
description: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Company description keywords',
},
industries: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Industries to filter by (array)',
},
locationCountry: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Country',
},
locationCity: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'City',
},
postalCode: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Postal code',
},
locationCountryList: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Multiple countries to filter by (array)',
},
locationCityList: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Multiple cities to filter by (array)',
},
specialities: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Company specialties (array)',
},
followers: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Minimum number of followers',
},
staffCount: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum staff count',
},
staffCountMin: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Minimum staff count',
},
staffCountMax: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum staff count',
},
currentPage: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Page number (default: 1)',
},
pageSize: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Results per page (default: 20)',
},
},
request: {
url: 'https://api.enrich.so/v1/api/search-company',
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params) => {
const body: Record<string, any> = {}
if (params.name) body.name = params.name
if (params.website) body.website = params.website
if (params.tagline) body.tagline = params.tagline
if (params.type) body.type = params.type
if (params.description) body.description = params.description
if (params.industries) body.industries = params.industries
if (params.locationCountry) body.location_country = params.locationCountry
if (params.locationCity) body.location_city = params.locationCity
if (params.postalCode) body.postal_code = params.postalCode
if (params.locationCountryList) body.location_country_list = params.locationCountryList
if (params.locationCityList) body.location_city_list = params.locationCityList
if (params.specialities) body.specialities = params.specialities
if (params.followers !== undefined) body.followers = params.followers
if (params.staffCount !== undefined) body.staff_count = params.staffCount
if (params.staffCountMin !== undefined) body.staff_count_min = params.staffCountMin
if (params.staffCountMax !== undefined) body.staff_count_max = params.staffCountMax
if (params.currentPage) body.current_page = params.currentPage
if (params.pageSize) body.page_size = params.pageSize
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
const resultData = data.data ?? {}
const companies =
resultData.companies?.map((company: any) => ({
companyName: company.company_name ?? '',
tagline: company.tagline ?? null,
webAddress: company.web_address ?? null,
industries: company.industries ?? [],
teamSize: company.team_size ?? null,
linkedInProfile: company.linkedin_profile ?? null,
})) ?? []
return {
success: true,
output: {
currentPage: resultData.current_page ?? 1,
totalPage: resultData.total_page ?? 1,
pageSize: resultData.page_size ?? 20,
companies,
},
}
},
outputs: {
currentPage: {
type: 'number',
description: 'Current page number',
},
totalPage: {
type: 'number',
description: 'Total number of pages',
},
pageSize: {
type: 'number',
description: 'Results per page',
},
companies: {
type: 'array',
description: 'Search results',
items: {
type: 'object',
properties: {
companyName: { type: 'string', description: 'Company name' },
tagline: { type: 'string', description: 'Company tagline' },
webAddress: { type: 'string', description: 'Website URL' },
industries: { type: 'array', description: 'Industries' },
teamSize: { type: 'number', description: 'Team size' },
linkedInProfile: { type: 'string', description: 'LinkedIn URL' },
},
},
},
},
}

View File

@@ -1,157 +0,0 @@
import type {
EnrichSearchCompanyActivitiesParams,
EnrichSearchCompanyActivitiesResponse,
} from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const searchCompanyActivitiesTool: ToolConfig<
EnrichSearchCompanyActivitiesParams,
EnrichSearchCompanyActivitiesResponse
> = {
id: 'enrich_search_company_activities',
name: 'Enrich Search Company Activities',
description: "Get a company's LinkedIn activities (posts, comments, or articles) by company ID.",
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
companyId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'LinkedIn company ID',
},
activityType: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Activity type: posts, comments, or articles',
},
paginationToken: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Pagination token for next page of results',
},
offset: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of records to skip (default: 0)',
},
},
request: {
url: (params) => {
const url = new URL('https://api.enrich.so/v1/api/search-company-activities')
url.searchParams.append('company_id', params.companyId.trim())
url.searchParams.append('activity_type', params.activityType)
if (params.paginationToken) {
url.searchParams.append('pagination_token', params.paginationToken)
}
if (params.offset !== undefined) {
url.searchParams.append('offset', String(params.offset))
}
return url.toString()
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const resultData = data.data ?? {}
const activities =
resultData.data?.map((activity: any) => ({
activityId: activity.activity_id ?? null,
commentary: activity.commentary ?? null,
linkedInUrl: activity.li_url ?? null,
timeElapsed: activity.time_elapsed ?? null,
numReactions: activity.num_reactions ?? activity.numReactions ?? null,
author: activity.author
? {
name: activity.author.name ?? null,
// API returns 'id' (integer) for company activities, 'profile_id' for people activities
profileId: String(
activity.author.id ?? activity.author.profile_id ?? activity.author.profileId ?? ''
),
// API returns 'logo_url' for company activities, 'profile_picture' for people activities
profilePicture:
activity.author.logo_url ??
activity.author.profile_picture ??
activity.author.profilePicture ??
null,
}
: null,
reactionBreakdown: {
likes: activity.reaction_breakdown?.likes ?? 0,
empathy: activity.reaction_breakdown?.empathy ?? 0,
other: activity.reaction_breakdown?.other ?? 0,
},
attachments: activity.attachments ?? [],
})) ?? []
return {
success: true,
output: {
paginationToken: resultData.pagination_token ?? null,
activityType: resultData.activity_type ?? '',
activities,
},
}
},
outputs: {
paginationToken: {
type: 'string',
description: 'Token for fetching next page',
optional: true,
},
activityType: {
type: 'string',
description: 'Type of activities returned',
},
activities: {
type: 'array',
description: 'Activities',
items: {
type: 'object',
properties: {
activityId: { type: 'string', description: 'Activity ID' },
commentary: { type: 'string', description: 'Activity text content' },
linkedInUrl: { type: 'string', description: 'Link to activity' },
timeElapsed: { type: 'string', description: 'Time elapsed since activity' },
numReactions: { type: 'number', description: 'Total number of reactions' },
author: {
type: 'object',
description: 'Activity author info',
properties: {
name: { type: 'string', description: 'Author name' },
profileId: { type: 'string', description: 'Profile ID' },
profilePicture: { type: 'string', description: 'Profile picture URL' },
},
},
reactionBreakdown: {
type: 'object',
description: 'Reactions',
properties: {
likes: { type: 'number', description: 'Likes' },
empathy: { type: 'number', description: 'Empathy reactions' },
other: { type: 'number', description: 'Other reactions' },
},
},
attachments: { type: 'array', description: 'Attachments' },
},
},
},
},
}

View File

@@ -1,149 +0,0 @@
import type {
EnrichSearchCompanyEmployeesParams,
EnrichSearchCompanyEmployeesResponse,
} from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const searchCompanyEmployeesTool: ToolConfig<
EnrichSearchCompanyEmployeesParams,
EnrichSearchCompanyEmployeesResponse
> = {
id: 'enrich_search_company_employees',
name: 'Enrich Search Company Employees',
description: 'Search for employees within specific companies by location and job title.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
companyIds: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Array of company IDs to search within',
},
country: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Country filter (e.g., United States)',
},
city: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'City filter (e.g., San Francisco)',
},
state: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'State filter (e.g., California)',
},
jobTitles: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Job titles to filter by (array)',
},
page: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Page number (default: 1)',
},
pageSize: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Results per page (default: 10)',
},
},
request: {
url: 'https://api.enrich.so/v1/api/search-company-employees',
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params) => {
const body: Record<string, any> = {}
if (params.companyIds) body.companyIds = params.companyIds
if (params.country) body.country = params.country
if (params.city) body.city = params.city
if (params.state) body.state = params.state
if (params.jobTitles) body.jobTitles = params.jobTitles
if (params.page) body.page = params.page
if (params.pageSize) body.page_size = params.pageSize
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
const resultData = data.data ?? {}
const profiles =
resultData.profiles?.map((profile: any) => ({
profileIdentifier: profile.profile_identifier ?? '',
givenName: profile.given_name ?? null,
familyName: profile.family_name ?? null,
currentPosition: profile.current_position ?? null,
profileImage: profile.profile_image ?? null,
externalProfileUrl: profile.external_profile_url ?? null,
city: profile.residence?.city ?? null,
country: profile.residence?.country ?? null,
expertSkills: profile.expert_skills ?? [],
})) ?? []
return {
success: true,
output: {
currentPage: resultData.current_page ?? 1,
totalPage: resultData.total_page ?? 1,
pageSize: resultData.page_size ?? profiles.length,
profiles,
},
}
},
outputs: {
currentPage: {
type: 'number',
description: 'Current page number',
},
totalPage: {
type: 'number',
description: 'Total number of pages',
},
pageSize: {
type: 'number',
description: 'Number of results per page',
},
profiles: {
type: 'array',
description: 'Employee profiles',
items: {
type: 'object',
properties: {
profileIdentifier: { type: 'string', description: 'Profile ID' },
givenName: { type: 'string', description: 'First name' },
familyName: { type: 'string', description: 'Last name' },
currentPosition: { type: 'string', description: 'Current job title' },
profileImage: { type: 'string', description: 'Profile image URL' },
externalProfileUrl: { type: 'string', description: 'LinkedIn URL' },
city: { type: 'string', description: 'City' },
country: { type: 'string', description: 'Country' },
expertSkills: { type: 'array', description: 'Skills' },
},
},
},
},
}

View File

@@ -1,77 +0,0 @@
import type { EnrichSearchLogoParams, EnrichSearchLogoResponse } from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const searchLogoTool: ToolConfig<EnrichSearchLogoParams, EnrichSearchLogoResponse> = {
id: 'enrich_search_logo',
name: 'Enrich Search Logo',
description: 'Get a company logo image URL by domain.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
url: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Company domain (e.g., google.com)',
},
},
request: {
url: (params) => {
const url = new URL('https://api.enrich.so/v1/api/search-logo')
url.searchParams.append('url', params.url.trim())
return url.toString()
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response, params) => {
// Check if response is JSON (error case) or binary (success case)
const contentType = response.headers.get('content-type') ?? ''
if (contentType.includes('application/json')) {
// API returned JSON, likely an error or no logo found
const data = await response.json()
return {
success: true,
output: {
logoUrl: data.logo_url ?? data.logoUrl ?? null,
domain: params?.url ?? '',
},
}
}
// API returns the image directly, construct the URL for access
const logoUrl = `https://api.enrich.so/v1/api/search-logo?url=${encodeURIComponent(params?.url ?? '')}`
return {
success: true,
output: {
logoUrl,
domain: params?.url ?? '',
},
}
},
outputs: {
logoUrl: {
type: 'string',
description: 'URL to fetch the company logo',
optional: true,
},
domain: {
type: 'string',
description: 'Domain that was looked up',
},
},
}

View File

@@ -1,249 +0,0 @@
import type { EnrichSearchPeopleParams, EnrichSearchPeopleResponse } from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const searchPeopleTool: ToolConfig<EnrichSearchPeopleParams, EnrichSearchPeopleResponse> = {
id: 'enrich_search_people',
name: 'Enrich Search People',
description:
'Search for professionals by various criteria including name, title, skills, education, and company.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
firstName: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'First name',
},
lastName: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Last name',
},
summary: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Professional summary keywords',
},
subTitle: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Job title/subtitle',
},
locationCountry: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Country',
},
locationCity: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'City',
},
locationState: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'State/province',
},
influencer: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Filter for influencers only',
},
premium: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Filter for premium accounts only',
},
language: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Primary language',
},
industry: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Industry',
},
currentJobTitles: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Current job titles (array)',
},
pastJobTitles: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Past job titles (array)',
},
skills: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Skills to search for (array)',
},
schoolNames: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'School names (array)',
},
certifications: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Certifications to filter by (array)',
},
degreeNames: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Degree names to filter by (array)',
},
studyFields: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Fields of study to filter by (array)',
},
currentCompanies: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Current company IDs to filter by (array of numbers)',
},
pastCompanies: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Past company IDs to filter by (array of numbers)',
},
currentPage: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Page number (default: 1)',
},
pageSize: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Results per page (default: 20)',
},
},
request: {
url: 'https://api.enrich.so/v1/api/search-people',
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params) => {
const body: Record<string, any> = {}
if (params.firstName) body.first_name = params.firstName
if (params.lastName) body.last_name = params.lastName
if (params.summary) body.summary = params.summary
if (params.subTitle) body.sub_title = params.subTitle
if (params.locationCountry) body.location_country = params.locationCountry
if (params.locationCity) body.location_city = params.locationCity
if (params.locationState) body.location_state = params.locationState
if (params.influencer !== undefined) body.influencer = params.influencer
if (params.premium !== undefined) body.premium = params.premium
if (params.language) body.language = params.language
if (params.industry) body.industry = params.industry
if (params.currentJobTitles) body.current_job_titles = params.currentJobTitles
if (params.pastJobTitles) body.past_job_titles = params.pastJobTitles
if (params.skills) body.skills = params.skills
if (params.schoolNames) body.school_names = params.schoolNames
if (params.certifications) body.certifications = params.certifications
if (params.degreeNames) body.degree_names = params.degreeNames
if (params.studyFields) body.study_fields = params.studyFields
if (params.currentCompanies) body.current_companies = params.currentCompanies
if (params.pastCompanies) body.past_companies = params.pastCompanies
if (params.currentPage) body.current_page = params.currentPage
if (params.pageSize) body.page_size = params.pageSize
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
const resultData = data.data ?? {}
const profiles =
resultData.profiles?.map((profile: any) => ({
profileIdentifier: profile.profile_identifier ?? '',
givenName: profile.given_name ?? null,
familyName: profile.family_name ?? null,
currentPosition: profile.current_position ?? null,
profileImage: profile.profile_image ?? null,
externalProfileUrl: profile.external_profile_url ?? null,
city: profile.residence?.city ?? null,
country: profile.residence?.country ?? null,
expertSkills: profile.expert_skills ?? [],
})) ?? []
return {
success: true,
output: {
currentPage: resultData.current_page ?? 1,
totalPage: resultData.total_page ?? 1,
pageSize: resultData.page_size ?? 20,
profiles,
},
}
},
outputs: {
currentPage: {
type: 'number',
description: 'Current page number',
},
totalPage: {
type: 'number',
description: 'Total number of pages',
},
pageSize: {
type: 'number',
description: 'Results per page',
},
profiles: {
type: 'array',
description: 'Search results',
items: {
type: 'object',
properties: {
profileIdentifier: { type: 'string', description: 'Profile ID' },
givenName: { type: 'string', description: 'First name' },
familyName: { type: 'string', description: 'Last name' },
currentPosition: { type: 'string', description: 'Current job title' },
profileImage: { type: 'string', description: 'Profile image URL' },
externalProfileUrl: { type: 'string', description: 'LinkedIn URL' },
city: { type: 'string', description: 'City' },
country: { type: 'string', description: 'Country' },
expertSkills: { type: 'array', description: 'Skills' },
},
},
},
},
}

View File

@@ -1,141 +0,0 @@
import type {
EnrichSearchPeopleActivitiesParams,
EnrichSearchPeopleActivitiesResponse,
} from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const searchPeopleActivitiesTool: ToolConfig<
EnrichSearchPeopleActivitiesParams,
EnrichSearchPeopleActivitiesResponse
> = {
id: 'enrich_search_people_activities',
name: 'Enrich Search People Activities',
description: "Get a person's LinkedIn activities (posts, comments, or articles) by profile ID.",
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
profileId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'LinkedIn profile ID',
},
activityType: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Activity type: posts, comments, or articles',
},
paginationToken: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Pagination token for next page of results',
},
},
request: {
url: (params) => {
const url = new URL('https://api.enrich.so/v1/api/search-people-activities')
url.searchParams.append('profile_id', params.profileId.trim())
url.searchParams.append('activity_type', params.activityType)
if (params.paginationToken) {
url.searchParams.append('pagination_token', params.paginationToken)
}
return url.toString()
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const resultData = data.data ?? {}
const activities =
resultData.data?.map((activity: any) => ({
activityId: activity.activity_id ?? activity.activityId ?? null,
commentary: activity.commentary ?? null,
linkedInUrl: activity.li_url ?? activity.linkedInUrl ?? null,
timeElapsed: activity.time_elapsed ?? activity.timeElapsed ?? null,
numReactions: activity.num_reactions ?? activity.numReactions ?? null,
author: activity.author
? {
name: activity.author.name ?? null,
profileId: String(activity.author.profile_id ?? activity.author.profileId ?? ''),
profilePicture:
activity.author.profile_picture ?? activity.author.profilePicture ?? null,
}
: null,
reactionBreakdown: {
likes: activity.reaction_breakdown?.likes ?? activity.reactionBreakdown?.likes ?? 0,
empathy: activity.reaction_breakdown?.empathy ?? activity.reactionBreakdown?.empathy ?? 0,
other: activity.reaction_breakdown?.other ?? activity.reactionBreakdown?.other ?? 0,
},
attachments: activity.attachments ?? [],
})) ?? []
return {
success: true,
output: {
paginationToken: resultData.pagination_token ?? null,
activityType: resultData.activity_type ?? '',
activities,
},
}
},
outputs: {
paginationToken: {
type: 'string',
description: 'Token for fetching next page',
optional: true,
},
activityType: {
type: 'string',
description: 'Type of activities returned',
},
activities: {
type: 'array',
description: 'Activities',
items: {
type: 'object',
properties: {
activityId: { type: 'string', description: 'Activity ID' },
commentary: { type: 'string', description: 'Activity text content' },
linkedInUrl: { type: 'string', description: 'Link to activity' },
timeElapsed: { type: 'string', description: 'Time elapsed since activity' },
numReactions: { type: 'number', description: 'Total number of reactions' },
author: {
type: 'object',
description: 'Activity author info',
properties: {
name: { type: 'string', description: 'Author name' },
profileId: { type: 'string', description: 'Profile ID' },
profilePicture: { type: 'string', description: 'Profile picture URL' },
},
},
reactionBreakdown: {
type: 'object',
description: 'Reactions',
properties: {
likes: { type: 'number', description: 'Likes' },
empathy: { type: 'number', description: 'Empathy reactions' },
other: { type: 'number', description: 'Other reactions' },
},
},
attachments: { type: 'array', description: 'Attachment URLs' },
},
},
},
},
}

View File

@@ -1,143 +0,0 @@
import type {
EnrichSearchPostCommentsParams,
EnrichSearchPostCommentsResponse,
} from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const searchPostCommentsTool: ToolConfig<
EnrichSearchPostCommentsParams,
EnrichSearchPostCommentsResponse
> = {
id: 'enrich_search_post_comments',
name: 'Enrich Search Post Comments',
description: 'Get comments on a LinkedIn post.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
postUrn: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'LinkedIn activity URN (e.g., urn:li:activity:7191163324208705536)',
},
page: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Page number (starts at 1, default: 1)',
},
},
request: {
url: (params) => {
const url = new URL('https://api.enrich.so/v1/api/search-comments')
url.searchParams.append('post_urn', params.postUrn.trim())
if (params.page !== undefined) {
url.searchParams.append('page', String(params.page))
}
return url.toString()
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const resultData = data.data ?? {}
const comments =
resultData.data?.map((comment: any) => ({
activityId: comment.activity_id ?? null,
commentary: comment.commentary ?? null,
linkedInUrl: comment.li_url ?? null,
commenter: {
profileId: comment.commenter?.profile_id ?? null,
firstName: comment.commenter?.first_name ?? null,
lastName: comment.commenter?.last_name ?? null,
subTitle: comment.commenter?.sub_title ?? comment.commenter?.subTitle ?? null,
profilePicture:
comment.commenter?.profile_picture ?? comment.commenter?.profilePicture ?? null,
backgroundImage:
comment.commenter?.background_image ?? comment.commenter?.backgroundImage ?? null,
entityUrn: comment.commenter?.entity_urn ?? comment.commenter?.entityUrn ?? null,
objectUrn: comment.commenter?.object_urn ?? comment.commenter?.objectUrn ?? null,
profileType: comment.commenter?.profile_type ?? comment.commenter?.profileType ?? null,
},
reactionBreakdown: {
likes: comment.reaction_breakdown?.likes ?? 0,
empathy: comment.reaction_breakdown?.empathy ?? 0,
other: comment.reaction_breakdown?.other ?? 0,
},
})) ?? []
return {
success: true,
output: {
page: resultData.page ?? 1,
totalPage: resultData.total_page ?? 1,
count: resultData.num ?? comments.length,
comments,
},
}
},
outputs: {
page: {
type: 'number',
description: 'Current page number',
},
totalPage: {
type: 'number',
description: 'Total number of pages',
},
count: {
type: 'number',
description: 'Number of comments returned',
},
comments: {
type: 'array',
description: 'Comments',
items: {
type: 'object',
properties: {
activityId: { type: 'string', description: 'Comment activity ID' },
commentary: { type: 'string', description: 'Comment text' },
linkedInUrl: { type: 'string', description: 'Link to comment' },
commenter: {
type: 'object',
description: 'Commenter info',
properties: {
profileId: { type: 'string', description: 'Profile ID' },
firstName: { type: 'string', description: 'First name' },
lastName: { type: 'string', description: 'Last name' },
subTitle: { type: 'string', description: 'Subtitle/headline' },
profilePicture: { type: 'string', description: 'Profile picture URL' },
backgroundImage: { type: 'string', description: 'Background image URL' },
entityUrn: { type: 'string', description: 'Entity URN' },
objectUrn: { type: 'string', description: 'Object URN' },
profileType: { type: 'string', description: 'Profile type' },
},
},
reactionBreakdown: {
type: 'object',
description: 'Reactions on the comment',
properties: {
likes: { type: 'number', description: 'Number of likes' },
empathy: { type: 'number', description: 'Number of empathy reactions' },
other: { type: 'number', description: 'Number of other reactions' },
},
},
},
},
},
},
}

View File

@@ -1,121 +0,0 @@
import type {
EnrichSearchPostReactionsParams,
EnrichSearchPostReactionsResponse,
} from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const searchPostReactionsTool: ToolConfig<
EnrichSearchPostReactionsParams,
EnrichSearchPostReactionsResponse
> = {
id: 'enrich_search_post_reactions',
name: 'Enrich Search Post Reactions',
description: 'Get reactions on a LinkedIn post with filtering by reaction type.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
postUrn: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'LinkedIn activity URN (e.g., urn:li:activity:7231931952839196672)',
},
reactionType: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'Reaction type filter: all, like, love, celebrate, insightful, or funny (default: all)',
},
page: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'Page number (starts at 1)',
},
},
request: {
url: (params) => {
const url = new URL('https://api.enrich.so/v1/api/search-reactions')
url.searchParams.append('post_urn', params.postUrn.trim())
url.searchParams.append('reaction_type', params.reactionType)
url.searchParams.append('page', String(params.page))
return url.toString()
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const resultData = data.data ?? {}
const reactions =
resultData.data?.map((reaction: any) => ({
reactionType: reaction.reaction_type ?? '',
reactor: {
name: reaction.reactor?.name ?? null,
subTitle: reaction.reactor?.sub_title ?? null,
profileId: reaction.reactor?.profile_id ?? null,
profilePicture: reaction.reactor?.profile_picture ?? null,
linkedInUrl: reaction.reactor?.li_url ?? null,
},
})) ?? []
return {
success: true,
output: {
page: resultData.page ?? 1,
totalPage: resultData.total_page ?? 1,
count: resultData.num ?? reactions.length,
reactions,
},
}
},
outputs: {
page: {
type: 'number',
description: 'Current page number',
},
totalPage: {
type: 'number',
description: 'Total number of pages',
},
count: {
type: 'number',
description: 'Number of reactions returned',
},
reactions: {
type: 'array',
description: 'Reactions',
items: {
type: 'object',
properties: {
reactionType: { type: 'string', description: 'Type of reaction' },
reactor: {
type: 'object',
description: 'Person who reacted',
properties: {
name: { type: 'string', description: 'Name' },
subTitle: { type: 'string', description: 'Job title' },
profileId: { type: 'string', description: 'Profile ID' },
profilePicture: { type: 'string', description: 'Profile picture URL' },
linkedInUrl: { type: 'string', description: 'LinkedIn URL' },
},
},
},
},
},
},
}

View File

@@ -1,120 +0,0 @@
import type { EnrichSearchPostsParams, EnrichSearchPostsResponse } from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const searchPostsTool: ToolConfig<EnrichSearchPostsParams, EnrichSearchPostsResponse> = {
id: 'enrich_search_posts',
name: 'Enrich Search Posts',
description: 'Search LinkedIn posts by keywords with date filtering.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
keywords: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Search keywords (e.g., "AI automation")',
},
datePosted: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Time filter (e.g., past_week, past_month)',
},
page: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Page number (default: 1)',
},
},
request: {
url: 'https://api.enrich.so/v1/api/search-posts',
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
Accept: 'application/json',
}),
body: (params) => {
const body: Record<string, any> = {
keywords: params.keywords,
}
if (params.datePosted) body.date_posted = params.datePosted
if (params.page) body.page = params.page
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
const posts =
data.data?.map((post: any) => ({
url: post.url ?? null,
postId: post.post_id ?? null,
author: {
name: post.author?.name ?? null,
headline: post.author?.headline ?? null,
linkedInUrl: post.author?.linkedin_url ?? null,
profileImage: post.author?.profile_image ?? null,
},
timestamp: post.post?.timestamp ?? null,
textContent: post.post?.text_content ?? null,
hashtags: post.post?.hashtags ?? [],
mediaUrls: post.post?.post_media_url ?? [],
reactions: post.engagement?.reactions ?? 0,
commentsCount: post.engagement?.comments_count ?? 0,
})) ?? []
return {
success: true,
output: {
count: data.count ?? posts.length,
posts,
},
}
},
outputs: {
count: {
type: 'number',
description: 'Total number of results',
},
posts: {
type: 'array',
description: 'Search results',
items: {
type: 'object',
properties: {
url: { type: 'string', description: 'Post URL' },
postId: { type: 'string', description: 'Post ID' },
author: {
type: 'object',
description: 'Author information',
properties: {
name: { type: 'string', description: 'Author name' },
headline: { type: 'string', description: 'Author headline' },
linkedInUrl: { type: 'string', description: 'Author LinkedIn URL' },
profileImage: { type: 'string', description: 'Author profile image' },
},
},
timestamp: { type: 'string', description: 'Post timestamp' },
textContent: { type: 'string', description: 'Post text content' },
hashtags: { type: 'array', description: 'Hashtags' },
mediaUrls: { type: 'array', description: 'Media URLs' },
reactions: { type: 'number', description: 'Number of reactions' },
commentsCount: { type: 'number', description: 'Number of comments' },
},
},
},
},
}

View File

@@ -1,146 +0,0 @@
import type {
EnrichSearchSimilarCompaniesParams,
EnrichSearchSimilarCompaniesResponse,
} from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const searchSimilarCompaniesTool: ToolConfig<
EnrichSearchSimilarCompaniesParams,
EnrichSearchSimilarCompaniesResponse
> = {
id: 'enrich_search_similar_companies',
name: 'Enrich Search Similar Companies',
description:
'Find companies similar to a given company by LinkedIn URL with filters for location and size.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
url: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'LinkedIn company URL (e.g., linkedin.com/company/google)',
},
accountLocation: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Filter by locations (array of country names)',
},
employeeSizeType: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Employee size filter type (e.g., RANGE)',
},
employeeSizeRange: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Employee size ranges (array of {start, end} objects)',
},
page: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Page number (default: 1)',
},
num: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of results per page',
},
},
request: {
url: 'https://api.enrich.so/v1/api/similar-companies',
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params) => {
const body: Record<string, any> = {
url: params.url.trim(),
}
if (params.accountLocation) {
body.account = body.account ?? {}
body.account.location = params.accountLocation
}
if (params.employeeSizeType || params.employeeSizeRange) {
body.account = body.account ?? {}
body.account.employeeSize = {
type: params.employeeSizeType ?? 'RANGE',
range: params.employeeSizeRange ?? [],
}
}
if (params.page) body.page = params.page
if (params.num) body.num = params.num
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
const content = data.data?.content ?? []
const companies = content.map((company: any) => ({
url: company.url ?? null,
name: company.name ?? null,
universalName: company.universalName ?? null,
type: company.type ?? null,
description: company.description ?? null,
phone: company.phone ?? null,
website: company.website ?? null,
logo: company.logo ?? null,
foundedYear: company.foundedYear ?? null,
staffTotal: company.staff?.total ?? null,
industries: company.industries ?? [],
relevancyScore: company.relevancy?.score ?? null,
relevancyValue: company.relevancy?.value ?? null,
}))
return {
success: true,
output: {
companies,
},
}
},
outputs: {
companies: {
type: 'array',
description: 'Similar companies',
items: {
type: 'object',
properties: {
url: { type: 'string', description: 'LinkedIn URL' },
name: { type: 'string', description: 'Company name' },
universalName: { type: 'string', description: 'Universal name' },
type: { type: 'string', description: 'Company type' },
description: { type: 'string', description: 'Description' },
phone: { type: 'string', description: 'Phone number' },
website: { type: 'string', description: 'Website URL' },
logo: { type: 'string', description: 'Logo URL' },
foundedYear: { type: 'number', description: 'Year founded' },
staffTotal: { type: 'number', description: 'Total staff' },
industries: { type: 'array', description: 'Industries' },
relevancyScore: { type: 'number', description: 'Relevancy score' },
relevancyValue: { type: 'string', description: 'Relevancy value' },
},
},
},
},
}

View File

@@ -1,742 +0,0 @@
import type { ToolResponse } from '@/tools/types'
/**
* Base params for all Enrich tools
*/
interface EnrichBaseParams {
apiKey: string
}
export interface EnrichCheckCreditsParams extends EnrichBaseParams {}
export interface EnrichCheckCreditsResponse extends ToolResponse {
output: {
totalCredits: number
creditsUsed: number
creditsRemaining: number
}
}
export interface EnrichEmailToProfileParams extends EnrichBaseParams {
email: string
inRealtime?: boolean
}
export interface EnrichEmailToProfileResponse extends ToolResponse {
output: {
displayName: string | null
firstName: string | null
lastName: string | null
headline: string | null
occupation: string | null
summary: string | null
location: string | null
country: string | null
linkedInUrl: string | null
photoUrl: string | null
connectionCount: number | null
isConnectionCountObfuscated: boolean | null
positionHistory: Array<{
title: string
company: string
startDate: string | null
endDate: string | null
location: string | null
}>
education: Array<{
school: string
degree: string | null
fieldOfStudy: string | null
startDate: string | null
endDate: string | null
}>
certifications: Array<{
name: string
authority: string | null
url: string | null
}>
skills: string[]
languages: string[]
locale: string | null
version: number | null
}
}
export interface EnrichEmailToPersonLiteParams extends EnrichBaseParams {
email: string
}
export interface EnrichEmailToPersonLiteResponse extends ToolResponse {
output: {
name: string | null
firstName: string | null
lastName: string | null
email: string | null
title: string | null
location: string | null
company: string | null
companyLocation: string | null
companyLinkedIn: string | null
profileId: string | null
schoolName: string | null
schoolUrl: string | null
linkedInUrl: string | null
photoUrl: string | null
followerCount: number | null
connectionCount: number | null
languages: string[]
projects: string[]
certifications: string[]
volunteerExperience: string[]
}
}
export interface EnrichLinkedInProfileParams extends EnrichBaseParams {
url: string
}
export interface EnrichLinkedInProfileResponse extends ToolResponse {
output: {
profileId: string | null
firstName: string | null
lastName: string | null
subTitle: string | null
profilePicture: string | null
backgroundImage: string | null
industry: string | null
location: string | null
followersCount: number | null
connectionsCount: number | null
premium: boolean
influencer: boolean
positions: Array<{
title: string
company: string
companyLogo: string | null
startDate: string | null
endDate: string | null
location: string | null
}>
education: Array<{
school: string
degree: string | null
fieldOfStudy: string | null
startDate: string | null
endDate: string | null
}>
websites: string[]
}
}
export interface EnrichFindEmailParams extends EnrichBaseParams {
fullName: string
companyDomain: string
}
export interface EnrichFindEmailResponse extends ToolResponse {
output: {
email: string | null
firstName: string | null
lastName: string | null
domain: string | null
found: boolean
acceptAll: boolean | null
}
}
export interface EnrichLinkedInToWorkEmailParams extends EnrichBaseParams {
linkedinProfile: string
}
export interface EnrichLinkedInToWorkEmailResponse extends ToolResponse {
output: {
email: string | null
found: boolean
status: string | null
}
}
export interface EnrichLinkedInToPersonalEmailParams extends EnrichBaseParams {
linkedinProfile: string
}
export interface EnrichLinkedInToPersonalEmailResponse extends ToolResponse {
output: {
email: string | null
found: boolean
status: string | null
}
}
export interface EnrichPhoneFinderParams extends EnrichBaseParams {
linkedinProfile: string
}
export interface EnrichPhoneFinderResponse extends ToolResponse {
output: {
profileUrl: string | null
mobileNumber: string | null
found: boolean
status: string | null
}
}
export interface EnrichEmailToPhoneParams extends EnrichBaseParams {
email: string
}
export interface EnrichEmailToPhoneResponse extends ToolResponse {
output: {
email: string | null
mobileNumber: string | null
found: boolean
status: string | null
}
}
export interface EnrichVerifyEmailParams extends EnrichBaseParams {
email: string
}
export interface EnrichVerifyEmailResponse extends ToolResponse {
output: {
email: string
status: string
result: string
confidenceScore: number
smtpProvider: string | null
mailDisposable: boolean
mailAcceptAll: boolean
free: boolean
}
}
export interface EnrichDisposableEmailCheckParams extends EnrichBaseParams {
email: string
}
export interface EnrichDisposableEmailCheckResponse extends ToolResponse {
output: {
email: string
score: number
testsPassed: string
passed: boolean
reason: string | null
mailServerIp: string | null
mxRecords: Array<{ host: string; pref: number }>
}
}
export interface EnrichEmailToIpParams extends EnrichBaseParams {
email: string
}
export interface EnrichEmailToIpResponse extends ToolResponse {
output: {
email: string
ip: string | null
found: boolean
}
}
export interface EnrichIpToCompanyParams extends EnrichBaseParams {
ip: string
}
export interface EnrichIpToCompanyResponse extends ToolResponse {
output: {
name: string | null
legalName: string | null
domain: string | null
domainAliases: string[]
sector: string | null
industry: string | null
phone: string | null
employees: number | null
revenue: string | null
location: {
city: string | null
state: string | null
country: string | null
timezone: string | null
}
linkedInUrl: string | null
twitterUrl: string | null
facebookUrl: string | null
}
}
export interface EnrichCompanyLookupParams extends EnrichBaseParams {
name?: string
domain?: string
}
export interface EnrichCompanyLookupResponse extends ToolResponse {
output: {
name: string | null
universalName: string | null
companyId: string | null
description: string | null
phone: string | null
linkedInUrl: string | null
websiteUrl: string | null
followers: number | null
staffCount: number | null
foundedDate: string | null
type: string | null
industries: string[]
specialties: string[]
headquarters: {
city: string | null
country: string | null
postalCode: string | null
line1: string | null
}
logo: string | null
coverImage: string | null
fundingRounds: Array<{
roundType: string
amount: number | null
currency: string | null
investors: string[]
}>
}
}
export interface EnrichCompanyFundingParams extends EnrichBaseParams {
domain: string
}
export interface EnrichCompanyFundingResponse extends ToolResponse {
output: {
legalName: string | null
employeeCount: number | null
headquarters: string | null
industry: string | null
totalFundingRaised: number | null
fundingRounds: Array<{
roundType: string
amount: number | null
date: string | null
investors: string[]
}>
monthlyVisits: number | null
trafficChange: number | null
itSpending: number | null
executives: Array<{
name: string
title: string
}>
}
}
export interface EnrichCompanyRevenueParams extends EnrichBaseParams {
domain: string
}
export interface EnrichCompanyRevenueResponse extends ToolResponse {
output: {
companyName: string | null
shortDescription: string | null
fullSummary: string | null
revenue: string | null
revenueMin: number | null
revenueMax: number | null
employeeCount: number | null
founded: string | null
ownership: string | null
status: string | null
website: string | null
ceo: {
name: string | null
designation: string | null
rating: number | null
}
socialLinks: {
linkedIn: string | null
twitter: string | null
facebook: string | null
}
totalFunding: string | null
fundingRounds: number | null
competitors: Array<{
name: string
revenue: string | null
employeeCount: number | null
headquarters: string | null
}>
}
}
export interface EnrichSearchPeopleParams extends EnrichBaseParams {
firstName?: string
lastName?: string
summary?: string
subTitle?: string
locationCountry?: string
locationCity?: string
locationState?: string
influencer?: boolean
premium?: boolean
language?: string
industry?: string
certifications?: string[]
degreeNames?: string[]
studyFields?: string[]
schoolNames?: string[]
currentCompanies?: number[]
pastCompanies?: number[]
currentJobTitles?: string[]
pastJobTitles?: string[]
skills?: string[]
currentPage?: number
pageSize?: number
}
export interface EnrichSearchPeopleResponse extends ToolResponse {
output: {
currentPage: number
totalPage: number
pageSize: number
profiles: Array<{
profileIdentifier: string
givenName: string | null
familyName: string | null
currentPosition: string | null
profileImage: string | null
externalProfileUrl: string | null
city: string | null
country: string | null
expertSkills: string[]
}>
}
}
export interface EnrichSearchCompanyParams extends EnrichBaseParams {
name?: string
website?: string
tagline?: string
type?: string
postalCode?: string
description?: string
industries?: string[]
locationCountry?: string
locationCountryList?: string[]
locationCity?: string
locationCityList?: string[]
specialities?: string[]
followers?: number
staffCount?: number
staffCountMin?: number
staffCountMax?: number
pageSize?: number
currentPage?: number
}
export interface EnrichSearchCompanyResponse extends ToolResponse {
output: {
currentPage: number
totalPage: number
pageSize: number
companies: Array<{
companyName: string
tagline: string | null
webAddress: string | null
industries: string[]
teamSize: number | null
linkedInProfile: string | null
}>
}
}
export interface EnrichSearchCompanyEmployeesParams extends EnrichBaseParams {
companyIds?: number[]
country?: string
city?: string
state?: string
jobTitles?: string[]
page?: number
pageSize?: number
}
export interface EnrichSearchCompanyEmployeesResponse extends ToolResponse {
output: {
currentPage: number
totalPage: number
pageSize: number
profiles: Array<{
profileIdentifier: string
givenName: string | null
familyName: string | null
currentPosition: string | null
profileImage: string | null
externalProfileUrl: string | null
city: string | null
country: string | null
expertSkills: string[]
}>
}
}
export interface EnrichSearchSimilarCompaniesParams extends EnrichBaseParams {
url: string
accountLocation?: string[]
employeeSizeType?: string
employeeSizeRange?: Array<{ start: number; end: number }>
page?: number
num?: number
}
export interface EnrichSearchSimilarCompaniesResponse extends ToolResponse {
output: {
companies: Array<{
url: string | null
name: string | null
universalName: string | null
type: string | null
description: string | null
phone: string | null
website: string | null
logo: string | null
foundedYear: number | null
staffTotal: number | null
industries: string[]
relevancyScore: number | null
relevancyValue: string | null
}>
}
}
export interface EnrichSalesPointerPeopleParams extends EnrichBaseParams {
page: number
filters: Array<{
type: string
values: Array<{
id: string
text: string
selectionType: 'INCLUDED' | 'EXCLUDED'
}>
selectedSubFilter?: number
}>
}
export interface EnrichSalesPointerPeopleResponse extends ToolResponse {
output: {
data: Array<{
name: string | null
summary: string | null
location: string | null
profilePicture: string | null
linkedInUrn: string | null
positions: Array<{
title: string
company: string
}>
education: Array<{
school: string
degree: string | null
}>
}>
pagination: {
totalCount: number
returnedCount: number
start: number
limit: number
}
}
}
export interface EnrichSearchPostsParams extends EnrichBaseParams {
keywords: string
datePosted?: string
page?: number
}
export interface EnrichSearchPostsResponse extends ToolResponse {
output: {
count: number
posts: Array<{
url: string | null
postId: string | null
author: {
name: string | null
headline: string | null
linkedInUrl: string | null
profileImage: string | null
}
timestamp: string | null
textContent: string | null
hashtags: string[]
mediaUrls: string[]
reactions: number
commentsCount: number
}>
}
}
export interface EnrichGetPostDetailsParams extends EnrichBaseParams {
url: string
}
export interface EnrichGetPostDetailsResponse extends ToolResponse {
output: {
postId: string | null
author: {
name: string | null
headline: string | null
linkedInUrl: string | null
profileImage: string | null
}
timestamp: string | null
textContent: string | null
hashtags: string[]
mediaUrls: string[]
reactions: number
commentsCount: number
}
}
export interface EnrichSearchPostReactionsParams extends EnrichBaseParams {
postUrn: string
reactionType: 'all' | 'like' | 'love' | 'celebrate' | 'insightful' | 'funny'
page: number
}
export interface EnrichSearchPostReactionsResponse extends ToolResponse {
output: {
page: number
totalPage: number
count: number
reactions: Array<{
reactionType: string
reactor: {
name: string | null
subTitle: string | null
profileId: string | null
profilePicture: string | null
linkedInUrl: string | null
}
}>
}
}
export interface EnrichSearchPostCommentsParams extends EnrichBaseParams {
postUrn: string
page?: number
}
export interface EnrichSearchPostCommentsResponse extends ToolResponse {
output: {
page: number
totalPage: number
count: number
comments: Array<{
activityId: string | null
commentary: string | null
linkedInUrl: string | null
commenter: {
profileId: string | null
firstName: string | null
lastName: string | null
subTitle: string | null
profilePicture: string | null
backgroundImage: string | null
entityUrn: string | null
objectUrn: string | null
profileType: string | null
}
reactionBreakdown: {
likes: number
empathy: number
other: number
}
}>
}
}
export interface EnrichSearchPeopleActivitiesParams extends EnrichBaseParams {
profileId: string
activityType: 'posts' | 'comments' | 'articles'
paginationToken?: string
}
export interface EnrichSearchPeopleActivitiesResponse extends ToolResponse {
output: {
paginationToken: string | null
activityType: string
activities: Array<{
activityId: string | null
commentary: string | null
linkedInUrl: string | null
timeElapsed: string | null
numReactions: number | null
author: {
name: string | null
profileId: string | null
profilePicture: string | null
} | null
reactionBreakdown: {
likes: number
empathy: number
other: number
}
attachments: string[]
}>
}
}
export interface EnrichSearchCompanyActivitiesParams extends EnrichBaseParams {
companyId: string
activityType: 'posts' | 'comments' | 'articles'
paginationToken?: string
offset?: number
}
export interface EnrichSearchCompanyActivitiesResponse extends ToolResponse {
output: {
paginationToken: string | null
activityType: string
activities: Array<{
activityId: string | null
commentary: string | null
linkedInUrl: string | null
timeElapsed: string | null
numReactions: number | null
author: {
name: string | null
profileId: string | null
profilePicture: string | null
} | null
reactionBreakdown: {
likes: number
empathy: number
other: number
}
attachments: string[]
}>
}
}
export interface EnrichReverseHashLookupParams extends EnrichBaseParams {
hash: string
}
export interface EnrichReverseHashLookupResponse extends ToolResponse {
output: {
hash: string
email: string | null
displayName: string | null
found: boolean
}
}
export interface EnrichSearchLogoParams extends EnrichBaseParams {
url: string
}
export interface EnrichSearchLogoResponse extends ToolResponse {
output: {
logoUrl: string | null
domain: string
}
}

View File

@@ -1,92 +0,0 @@
import type { EnrichVerifyEmailParams, EnrichVerifyEmailResponse } from '@/tools/enrich/types'
import type { ToolConfig } from '@/tools/types'
export const verifyEmailTool: ToolConfig<EnrichVerifyEmailParams, EnrichVerifyEmailResponse> = {
id: 'enrich_verify_email',
name: 'Enrich Verify Email',
description:
'Verify an email address for deliverability, including catch-all detection and provider identification.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Enrich API key',
},
email: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Email address to verify (e.g., john.doe@example.com)',
},
},
request: {
url: (params) => {
const url = new URL('https://api.enrich.so/v1/api/verify-email')
url.searchParams.append('email', params.email.trim())
return url.toString()
},
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
email: data.email ?? '',
status: data.status ?? '',
result: data.result ?? '',
confidenceScore: data.confidenceScore ?? 0,
smtpProvider: data.smtpProvider ?? null,
mailDisposable: data.mailDisposable ?? false,
mailAcceptAll: data.mailAcceptAll ?? false,
free: data.free ?? false,
},
}
},
outputs: {
email: {
type: 'string',
description: 'Email address verified',
},
status: {
type: 'string',
description: 'Verification status',
},
result: {
type: 'string',
description: 'Deliverability result (deliverable, undeliverable, etc.)',
},
confidenceScore: {
type: 'number',
description: 'Confidence score (0-100)',
},
smtpProvider: {
type: 'string',
description: 'Email service provider (e.g., Google, Microsoft)',
optional: true,
},
mailDisposable: {
type: 'boolean',
description: 'Whether the email is from a disposable provider',
},
mailAcceptAll: {
type: 'boolean',
description: 'Whether the domain is a catch-all domain',
},
free: {
type: 'boolean',
description: 'Whether the email uses a free email service',
},
},
}

View File

@@ -232,37 +232,6 @@ import {
elasticsearchUpdateDocumentTool, elasticsearchUpdateDocumentTool,
} from '@/tools/elasticsearch' } from '@/tools/elasticsearch'
import { elevenLabsTtsTool } from '@/tools/elevenlabs' import { elevenLabsTtsTool } from '@/tools/elevenlabs'
import {
enrichCheckCreditsTool,
enrichCompanyFundingTool,
enrichCompanyLookupTool,
enrichCompanyRevenueTool,
enrichDisposableEmailCheckTool,
enrichEmailToIpTool,
enrichEmailToPersonLiteTool,
enrichEmailToPhoneTool,
enrichEmailToProfileTool,
enrichFindEmailTool,
enrichGetPostDetailsTool,
enrichIpToCompanyTool,
enrichLinkedInProfileTool,
enrichLinkedInToPersonalEmailTool,
enrichLinkedInToWorkEmailTool,
enrichPhoneFinderTool,
enrichReverseHashLookupTool,
enrichSalesPointerPeopleTool,
enrichSearchCompanyActivitiesTool,
enrichSearchCompanyEmployeesTool,
enrichSearchCompanyTool,
enrichSearchLogoTool,
enrichSearchPeopleActivitiesTool,
enrichSearchPeopleTool,
enrichSearchPostCommentsTool,
enrichSearchPostReactionsTool,
enrichSearchPostsTool,
enrichSearchSimilarCompaniesTool,
enrichVerifyEmailTool,
} from '@/tools/enrich'
import { import {
exaAnswerTool, exaAnswerTool,
exaFindSimilarLinksTool, exaFindSimilarLinksTool,
@@ -2426,35 +2395,6 @@ export const tools: Record<string, ToolConfig> = {
elasticsearch_list_indices: elasticsearchListIndicesTool, elasticsearch_list_indices: elasticsearchListIndicesTool,
elasticsearch_cluster_health: elasticsearchClusterHealthTool, elasticsearch_cluster_health: elasticsearchClusterHealthTool,
elasticsearch_cluster_stats: elasticsearchClusterStatsTool, elasticsearch_cluster_stats: elasticsearchClusterStatsTool,
enrich_check_credits: enrichCheckCreditsTool,
enrich_company_funding: enrichCompanyFundingTool,
enrich_company_lookup: enrichCompanyLookupTool,
enrich_company_revenue: enrichCompanyRevenueTool,
enrich_disposable_email_check: enrichDisposableEmailCheckTool,
enrich_email_to_ip: enrichEmailToIpTool,
enrich_email_to_person_lite: enrichEmailToPersonLiteTool,
enrich_email_to_phone: enrichEmailToPhoneTool,
enrich_email_to_profile: enrichEmailToProfileTool,
enrich_find_email: enrichFindEmailTool,
enrich_get_post_details: enrichGetPostDetailsTool,
enrich_ip_to_company: enrichIpToCompanyTool,
enrich_linkedin_profile: enrichLinkedInProfileTool,
enrich_linkedin_to_personal_email: enrichLinkedInToPersonalEmailTool,
enrich_linkedin_to_work_email: enrichLinkedInToWorkEmailTool,
enrich_phone_finder: enrichPhoneFinderTool,
enrich_reverse_hash_lookup: enrichReverseHashLookupTool,
enrich_sales_pointer_people: enrichSalesPointerPeopleTool,
enrich_search_company: enrichSearchCompanyTool,
enrich_search_company_activities: enrichSearchCompanyActivitiesTool,
enrich_search_company_employees: enrichSearchCompanyEmployeesTool,
enrich_search_logo: enrichSearchLogoTool,
enrich_search_people: enrichSearchPeopleTool,
enrich_search_people_activities: enrichSearchPeopleActivitiesTool,
enrich_search_post_comments: enrichSearchPostCommentsTool,
enrich_search_post_reactions: enrichSearchPostReactionsTool,
enrich_search_posts: enrichSearchPostsTool,
enrich_search_similar_companies: enrichSearchSimilarCompaniesTool,
enrich_verify_email: enrichVerifyEmailTool,
exa_search: exaSearchTool, exa_search: exaSearchTool,
exa_get_contents: exaGetContentsTool, exa_get_contents: exaGetContentsTool,
exa_find_similar_links: exaFindSimilarLinksTool, exa_find_similar_links: exaFindSimilarLinksTool,

View File

@@ -0,0 +1 @@
ALTER TABLE "workflow_blocks" ADD COLUMN "locked" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -1044,6 +1044,13 @@
"when": 1769656977701, "when": 1769656977701,
"tag": "0149_next_cerise", "tag": "0149_next_cerise",
"breakpoints": true "breakpoints": true
},
{
"idx": 150,
"version": "7",
"when": 1769897862156,
"tag": "0150_flimsy_hemingway",
"breakpoints": true
} }
] ]
} }

View File

@@ -189,6 +189,7 @@ export const workflowBlocks = pgTable(
isWide: boolean('is_wide').notNull().default(false), isWide: boolean('is_wide').notNull().default(false),
advancedMode: boolean('advanced_mode').notNull().default(false), advancedMode: boolean('advanced_mode').notNull().default(false),
triggerMode: boolean('trigger_mode').notNull().default(false), triggerMode: boolean('trigger_mode').notNull().default(false),
locked: boolean('locked').notNull().default(false),
height: decimal('height').notNull().default('0'), height: decimal('height').notNull().default('0'),
subBlocks: jsonb('sub_blocks').notNull().default('{}'), subBlocks: jsonb('sub_blocks').notNull().default('{}'),

View File

@@ -21,6 +21,7 @@ export interface BlockFactoryOptions {
triggerMode?: boolean triggerMode?: boolean
data?: BlockData data?: BlockData
parentId?: string parentId?: string
locked?: boolean
} }
/** /**
@@ -67,6 +68,7 @@ export function createBlock(options: BlockFactoryOptions = {}): any {
height: options.height ?? 0, height: options.height ?? 0,
advancedMode: options.advancedMode ?? false, advancedMode: options.advancedMode ?? false,
triggerMode: options.triggerMode ?? false, triggerMode: options.triggerMode ?? false,
locked: options.locked ?? false,
data: Object.keys(data).length > 0 ? data : undefined, data: Object.keys(data).length > 0 ? data : undefined,
layout: {}, layout: {},
} }