diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index a31b85585..2e1e48778 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -5421,3 +5421,18 @@ z' ) } + +export function EnrichSoIcon(props: SVGProps) { + return ( + + + + + ) +} diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index b17b4a6f0..308902d64 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -29,6 +29,7 @@ import { DynamoDBIcon, ElasticsearchIcon, ElevenLabsIcon, + EnrichSoIcon, ExaAIIcon, EyeIcon, FirecrawlIcon, @@ -160,6 +161,7 @@ export const blockTypeToIconMap: Record = { dynamodb: DynamoDBIcon, elasticsearch: ElasticsearchIcon, elevenlabs: ElevenLabsIcon, + enrich: EnrichSoIcon, exa: ExaAIIcon, file_v2: DocumentIcon, firecrawl: FirecrawlIcon, diff --git a/apps/docs/content/docs/en/tools/enrich.mdx b/apps/docs/content/docs/en/tools/enrich.mdx new file mode 100644 index 000000000..5e03c3e6f --- /dev/null +++ b/apps/docs/content/docs/en/tools/enrich.mdx @@ -0,0 +1,930 @@ +--- +title: Enrich +description: B2B data enrichment and LinkedIn intelligence with Enrich.so +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* 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 | + + diff --git a/apps/docs/content/docs/en/tools/github.mdx b/apps/docs/content/docs/en/tools/github.mdx index 2c81c5fa2..37b07dc7a 100644 --- a/apps/docs/content/docs/en/tools/github.mdx +++ b/apps/docs/content/docs/en/tools/github.mdx @@ -10,6 +10,23 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" color="#181C1E" /> +{/* MANUAL-CONTENT-START:intro */} +[GitHub](https://github.com/) is the world’s 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 team’s 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 organization’s GitHub strategy. +{/* MANUAL-CONTENT-END */} + + ## 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. diff --git a/apps/docs/content/docs/en/tools/google_docs.mdx b/apps/docs/content/docs/en/tools/google_docs.mdx index 02d55b6dd..35dbd4eae 100644 --- a/apps/docs/content/docs/en/tools/google_docs.mdx +++ b/apps/docs/content/docs/en/tools/google_docs.mdx @@ -11,55 +11,17 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" /> {/* MANUAL-CONTENT-START:intro */} -[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](https://docs.google.com) is Google’s 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. -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. +Google Docs empowers individuals and teams to: - +- **Create and format documents:** Develop rich text documents with advanced formatting, images, and tables. +- **Collaborate and comment:** Multiple users can edit and comment with suggestions instantly. +- **Track changes and version history:** Review, revert, and manage revisions over time. +- **Access from any device:** Work on documents from web, mobile, or desktop with full cloud synchronization. +- **Integrate across Google services:** Connect Docs with Drive, Sheets, Slides, and external platforms for powerful workflows. -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. - - - -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. - - - -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. +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. {/* MANUAL-CONTENT-END */} diff --git a/apps/docs/content/docs/en/tools/google_drive.mdx b/apps/docs/content/docs/en/tools/google_drive.mdx index 8aca5fc9e..f6a5fcb17 100644 --- a/apps/docs/content/docs/en/tools/google_drive.mdx +++ b/apps/docs/content/docs/en/tools/google_drive.mdx @@ -11,30 +11,18 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" /> {/* MANUAL-CONTENT-START:intro */} -[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](https://drive.google.com) is Google’s 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. -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. +Google Drive enables individuals and teams to: - +- **Store files in the cloud:** Access documents, images, videos, and more from anywhere with internet connectivity. +- **Organize and manage content:** Create and arrange folders, use naming conventions, and leverage search for fast retrieval. +- **Share and collaborate:** Control file and folder permissions, share with individuals or groups, and collaborate in real time. +- **Leverage powerful search:** Quickly locate files using Google’s search technology. +- **Access across devices:** Work with your files on desktop, mobile, or web with full synchronization. +- **Integrate deeply across Google services:** Connect with Google Docs, Sheets, Slides, and partner applications in your workflows. -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. +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. {/* MANUAL-CONTENT-END */} diff --git a/apps/docs/content/docs/en/tools/google_search.mdx b/apps/docs/content/docs/en/tools/google_search.mdx index 4b7b535b9..962c041de 100644 --- a/apps/docs/content/docs/en/tools/google_search.mdx +++ b/apps/docs/content/docs/en/tools/google_search.mdx @@ -11,29 +11,9 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" /> {/* MANUAL-CONTENT-START:intro */} -[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. +[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. -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. - - - -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. +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. {/* MANUAL-CONTENT-END */} diff --git a/apps/docs/content/docs/en/tools/memory.mdx b/apps/docs/content/docs/en/tools/memory.mdx index 9fb09ade4..ba5fcddf8 100644 --- a/apps/docs/content/docs/en/tools/memory.mdx +++ b/apps/docs/content/docs/en/tools/memory.mdx @@ -10,6 +10,20 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" 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 + +Sim’s 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 Integrate Memory into the workflow. Can add, get a memory, get all memories, and delete memories. diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index 30ea31630..61b20cfa9 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -24,6 +24,7 @@ "dynamodb", "elasticsearch", "elevenlabs", + "enrich", "exa", "file", "firecrawl", diff --git a/apps/docs/content/docs/en/tools/notion.mdx b/apps/docs/content/docs/en/tools/notion.mdx index 63529a832..37a663af5 100644 --- a/apps/docs/content/docs/en/tools/notion.mdx +++ b/apps/docs/content/docs/en/tools/notion.mdx @@ -10,6 +10,21 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" 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 Integrate with Notion into the workflow. Can read page, read database, create page, create database, append content, query database, and search workspace. diff --git a/apps/docs/content/docs/en/tools/slack.mdx b/apps/docs/content/docs/en/tools/slack.mdx index f741ca3c6..1471c8800 100644 --- a/apps/docs/content/docs/en/tools/slack.mdx +++ b/apps/docs/content/docs/en/tools/slack.mdx @@ -13,16 +13,6 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" {/* 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. - - With Slack, you can: - **Automate agent notifications**: Send real-time updates from your Sim agents to any Slack channel diff --git a/apps/sim/app/(auth)/sso/page.tsx b/apps/sim/app/(auth)/sso/page.tsx index 18ff14f90..49bf30f1c 100644 --- a/apps/sim/app/(auth)/sso/page.tsx +++ b/apps/sim/app/(auth)/sso/page.tsx @@ -1,6 +1,6 @@ import { redirect } from 'next/navigation' import { getEnv, isTruthy } from '@/lib/core/config/env' -import SSOForm from '@/app/(auth)/sso/sso-form' +import SSOForm from '@/ee/sso/components/sso-form' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts index 5c4ba7921..2b0859143 100644 --- a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts +++ b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts @@ -8,6 +8,7 @@ import { verifyCronAuth } from '@/lib/auth/internal' const logger = createLogger('CleanupStaleExecutions') const STALE_THRESHOLD_MINUTES = 30 +const MAX_INT32 = 2_147_483_647 export async function GET(request: NextRequest) { try { @@ -45,13 +46,14 @@ export async function GET(request: NextRequest) { try { const staleDurationMs = Date.now() - new Date(execution.startedAt).getTime() const staleDurationMinutes = Math.round(staleDurationMs / 60000) + const totalDurationMs = Math.min(staleDurationMs, MAX_INT32) await db .update(workflowExecutionLogs) .set({ status: 'failed', endedAt: new Date(), - totalDurationMs: staleDurationMs, + totalDurationMs, executionData: sql`jsonb_set( COALESCE(execution_data, '{}'::jsonb), ARRAY['error'], diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index 1bc217177..2359d9019 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -284,7 +284,7 @@ async function handleToolsCall( content: [ { type: 'text', text: JSON.stringify(executeResult.output || executeResult, null, 2) }, ], - isError: !executeResult.success, + isError: executeResult.success === false, } return NextResponse.json(createResponse(id, result)) diff --git a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts index 0c98a52bf..cd716fe15 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts @@ -20,6 +20,7 @@ import { z } from 'zod' import { getEmailSubject, renderInvitationEmail } from '@/components/emails' import { getSession } from '@/lib/auth' import { hasAccessControlAccess } from '@/lib/billing' +import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' import { requireStripeClient } from '@/lib/billing/stripe-client' import { getBaseUrl } from '@/lib/core/utils/urls' import { sendEmail } from '@/lib/messaging/email/mailer' @@ -501,6 +502,18 @@ export async function PUT( } } + if (status === 'accepted') { + try { + await syncUsageLimitsFromSubscription(session.user.id) + } catch (syncError) { + logger.error('Failed to sync usage limits after joining org', { + userId: session.user.id, + organizationId, + error: syncError, + }) + } + } + logger.info(`Organization invitation ${status}`, { organizationId, invitationId, diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts index 124d70957..905628696 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts @@ -29,7 +29,7 @@ import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' import { InvitationsNotAllowedError, validateInvitationsAllowed, -} from '@/executor/utils/permission-check' +} from '@/ee/access-control/utils/permission-check' const logger = createLogger('OrganizationInvitations') diff --git a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts index c00777ce3..16a0023ed 100644 --- a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts +++ b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts @@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' +import { hasActiveSubscription } from '@/lib/billing' const logger = createLogger('SubscriptionTransferAPI') @@ -88,6 +89,14 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ ) } + // Check if org already has an active subscription (prevent duplicates) + if (await hasActiveSubscription(organizationId)) { + return NextResponse.json( + { error: 'Organization already has an active subscription' }, + { status: 409 } + ) + } + await db .update(subscription) .set({ referenceId: organizationId }) diff --git a/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts b/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts index 9f0374f5f..0caf2c655 100644 --- a/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts +++ b/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts @@ -203,6 +203,10 @@ export const PATCH = withAdminAuthParams(async (request, context) = } updateData.billingBlocked = body.billingBlocked + // Clear the reason when unblocking + if (body.billingBlocked === false) { + updateData.billingBlockedReason = null + } updated.push('billingBlocked') } diff --git a/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts b/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts index 647012589..dd59f3338 100644 --- a/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute-from-block/route.ts @@ -1,6 +1,4 @@ -import { db, workflow as workflowTable } from '@sim/db' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v4 as uuidv4 } from 'uuid' import { z } from 'zod' @@ -8,6 +6,7 @@ import { checkHybridAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { SSE_HEADERS } from '@/lib/core/utils/sse' import { markExecutionCancelled } from '@/lib/execution/cancellation' +import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' import { createSSECallbacks } from '@/lib/workflows/executor/execution-events' @@ -75,12 +74,31 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const { startBlockId, sourceSnapshot, input } = validation.data const executionId = uuidv4() - const [workflowRecord] = await db - .select({ workspaceId: workflowTable.workspaceId, userId: workflowTable.userId }) - .from(workflowTable) - .where(eq(workflowTable.id, workflowId)) - .limit(1) + // Run preprocessing checks (billing, rate limits, usage limits) + const preprocessResult = await preprocessExecution({ + workflowId, + userId, + triggerType: 'manual', + executionId, + requestId, + checkRateLimit: false, // Manual executions don't rate limit + checkDeployment: false, // Run-from-block doesn't require deployment + }) + if (!preprocessResult.success) { + const { error } = preprocessResult + logger.warn(`[${requestId}] Preprocessing failed for run-from-block`, { + workflowId, + error: error?.message, + statusCode: error?.statusCode, + }) + return NextResponse.json( + { error: error?.message || 'Execution blocked' }, + { status: error?.statusCode || 500 } + ) + } + + const workflowRecord = preprocessResult.workflowRecord if (!workflowRecord?.workspaceId) { return NextResponse.json({ error: 'Workflow not found or has no workspace' }, { status: 404 }) } @@ -92,6 +110,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: workflowId, startBlockId, executedBlocksCount: sourceSnapshot.executedBlocks.length, + billingActorUserId: preprocessResult.actorUserId, }) const loggingSession = new LoggingSession(workflowId, executionId, 'manual', requestId) diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts index 202559142..ac3545885 100644 --- a/apps/sim/app/api/workspaces/invitations/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/route.test.ts @@ -102,7 +102,7 @@ describe('Workspace Invitations API Route', () => { inArray: vi.fn().mockImplementation((field, values) => ({ type: 'inArray', field, values })), })) - vi.doMock('@/executor/utils/permission-check', () => ({ + vi.doMock('@/ee/access-control/utils/permission-check', () => ({ validateInvitationsAllowed: vi.fn().mockResolvedValue(undefined), InvitationsNotAllowedError: class InvitationsNotAllowedError extends Error { constructor() { diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts index bd70b9dc9..e6116d840 100644 --- a/apps/sim/app/api/workspaces/invitations/route.ts +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -21,7 +21,7 @@ import { getFromEmailAddress } from '@/lib/messaging/email/utils' import { InvitationsNotAllowedError, validateInvitationsAllowed, -} from '@/executor/utils/permission-check' +} from '@/ee/access-control/utils/permission-check' export const dynamic = 'force-dynamic' @@ -38,7 +38,6 @@ export async function GET(req: NextRequest) { } try { - // Get all workspaces where the user has permissions const userWorkspaces = await db .select({ id: workspace.id }) .from(workspace) @@ -55,10 +54,8 @@ export async function GET(req: NextRequest) { return NextResponse.json({ invitations: [] }) } - // Get all workspaceIds where the user is a member const workspaceIds = userWorkspaces.map((w) => w.id) - // Find all invitations for those workspaces const invitations = await db .select() .from(workspaceInvitation) diff --git a/apps/sim/app/chat/[identifier]/chat.tsx b/apps/sim/app/chat/[identifier]/chat.tsx index 94082ffec..549e450d4 100644 --- a/apps/sim/app/chat/[identifier]/chat.tsx +++ b/apps/sim/app/chat/[identifier]/chat.tsx @@ -14,11 +14,11 @@ import { ChatMessageContainer, EmailAuth, PasswordAuth, - SSOAuth, VoiceInterface, } from '@/app/chat/components' import { CHAT_ERROR_MESSAGES, CHAT_REQUEST_TIMEOUT_MS } from '@/app/chat/constants' import { useAudioStreaming, useChatStreaming } from '@/app/chat/hooks' +import SSOAuth from '@/ee/sso/components/sso-auth' const logger = createLogger('ChatClient') diff --git a/apps/sim/app/chat/components/index.ts b/apps/sim/app/chat/components/index.ts index 4be7ea2f1..eef5a82c4 100644 --- a/apps/sim/app/chat/components/index.ts +++ b/apps/sim/app/chat/components/index.ts @@ -1,6 +1,5 @@ export { default as EmailAuth } from './auth/email/email-auth' export { default as PasswordAuth } from './auth/password/password-auth' -export { default as SSOAuth } from './auth/sso/sso-auth' export { ChatErrorState } from './error-state/error-state' export { ChatHeader } from './header/header' export { ChatInput } from './input/input' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx index a5c1eadeb..a449539d5 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx @@ -1,7 +1,7 @@ import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' -import { getUserPermissionConfig } from '@/executor/utils/permission-check' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { Knowledge } from './knowledge' interface KnowledgePageProps { @@ -23,7 +23,6 @@ export default async function KnowledgePage({ params }: KnowledgePageProps) { redirect('/') } - // Check permission group restrictions const permissionConfig = await getUserPermissionConfig(session.user.id) if (permissionConfig?.hideKnowledgeBaseTab) { redirect(`/workspace/${workspaceId}`) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx index 5a073a04b..c7ae2bf61 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-list/logs-list.tsx @@ -6,11 +6,11 @@ import Link from 'next/link' import { List, type RowComponentProps, useListRef } from 'react-window' import { Badge, buttonVariants } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' +import { formatDuration } from '@/lib/core/utils/formatting' import { DELETED_WORKFLOW_COLOR, DELETED_WORKFLOW_LABEL, formatDate, - formatDuration, getDisplayStatus, LOG_COLUMNS, StatusBadge, @@ -113,7 +113,7 @@ const LogRow = memo(
- {formatDuration(log.duration) || '—'} + {formatDuration(log.duration, { precision: 2 }) || '—'}
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts index d21561b97..570262d10 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts @@ -1,6 +1,7 @@ import React from 'react' import { format } from 'date-fns' import { Badge } from '@/components/emcn' +import { formatDuration } from '@/lib/core/utils/formatting' import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options' import { getBlock } from '@/blocks/registry' import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' @@ -362,47 +363,14 @@ export function mapToExecutionLogAlt(log: RawLogResponse): ExecutionLog { } } -/** - * Format duration for display in logs UI - * If duration is under 1 second, displays as milliseconds (e.g., "500ms") - * If duration is 1 second or more, displays as seconds (e.g., "1.23s") - * @param duration - Duration string (e.g., "500ms") or null - * @returns Formatted duration string or null - */ -export function formatDuration(duration: string | null): string | null { - if (!duration) return null - - // Extract numeric value from duration string (e.g., "500ms" -> 500) - const ms = Number.parseInt(duration.replace(/[^0-9]/g, ''), 10) - - if (!Number.isFinite(ms)) return duration - - if (ms < 1000) { - return `${ms}ms` - } - - // Convert to seconds with up to 2 decimal places - const seconds = ms / 1000 - return `${seconds.toFixed(2).replace(/\.?0+$/, '')}s` -} - /** * Format latency value for display in dashboard UI - * If latency is under 1 second, displays as milliseconds (e.g., "500ms") - * If latency is 1 second or more, displays as seconds (e.g., "1.23s") * @param ms - Latency in milliseconds (number) * @returns Formatted latency string */ export function formatLatency(ms: number): string { if (!Number.isFinite(ms) || ms <= 0) return '—' - - if (ms < 1000) { - return `${Math.round(ms)}ms` - } - - // Convert to seconds with up to 2 decimal places - const seconds = ms / 1000 - return `${seconds.toFixed(2).replace(/\.?0+$/, '')}s` + return formatDuration(ms, { precision: 2 }) ?? '—' } export const formatDate = (dateString: string) => { diff --git a/apps/sim/app/workspace/[workspaceId]/templates/page.tsx b/apps/sim/app/workspace/[workspaceId]/templates/page.tsx index 9955c2433..8e5194cee 100644 --- a/apps/sim/app/workspace/[workspaceId]/templates/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/templates/page.tsx @@ -6,7 +6,7 @@ import { getSession } from '@/lib/auth' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' import type { Template as WorkspaceTemplate } from '@/app/workspace/[workspaceId]/templates/templates' import Templates from '@/app/workspace/[workspaceId]/templates/templates' -import { getUserPermissionConfig } from '@/executor/utils/permission-check' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' interface TemplatesPageProps { params: Promise<{ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block/thinking-block.tsx index de632ca5f..3c95d83d4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block/thinking-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block/thinking-block.tsx @@ -3,6 +3,7 @@ import { memo, useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { ChevronUp } from 'lucide-react' +import { formatDuration } from '@/lib/core/utils/formatting' import { CopilotMarkdownRenderer } from '../markdown-renderer' /** Removes thinking tags (raw or escaped) and special tags from streamed content */ @@ -241,15 +242,11 @@ export function ThinkingBlock({ return () => window.clearInterval(intervalId) }, [isStreaming, isExpanded, userHasScrolledAway]) - /** Formats duration in milliseconds to seconds (minimum 1s) */ - const formatDuration = (ms: number) => { - const seconds = Math.max(1, Math.round(ms / 1000)) - return `${seconds}s` - } - const hasContent = cleanContent.length > 0 const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags - const durationText = `${label} for ${formatDuration(duration)}` + // Round to nearest second (minimum 1s) to match original behavior + const roundedMs = Math.max(1000, Math.round(duration / 1000) * 1000) + const durationText = `${label} for ${formatDuration(roundedMs)}` const getStreamingLabel = (lbl: string) => { if (lbl === 'Thought') return 'Thinking' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx index d22542375..f6ee0679a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx @@ -15,6 +15,7 @@ import { hasInterrupt as hasInterruptFromConfig, isSpecialTool as isSpecialToolFromConfig, } from '@/lib/copilot/tools/client/ui-config' +import { formatDuration } from '@/lib/core/utils/formatting' import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer' import { SmoothStreamingText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming' import { ThinkingBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block' @@ -848,13 +849,10 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({ (allParsed.options && Object.keys(allParsed.options).length > 0) ) - const formatDuration = (ms: number) => { - const seconds = Math.max(1, Math.round(ms / 1000)) - return `${seconds}s` - } - const outerLabel = getSubagentCompletionLabel(toolCall.name) - const durationText = `${outerLabel} for ${formatDuration(duration)}` + // Round to nearest second (minimum 1s) to match original behavior + const roundedMs = Math.max(1000, Math.round(duration / 1000) * 1000) + const durationText = `${outerLabel} for ${formatDuration(roundedMs)}` const renderCollapsibleContent = () => ( <> diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index d8c905560..79087c7c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -45,7 +45,7 @@ export function CredentialSelector({ previewValue, }: CredentialSelectorProps) { const [showOAuthModal, setShowOAuthModal] = useState(false) - const [inputValue, setInputValue] = useState('') + const [editingValue, setEditingValue] = useState('') const [isEditing, setIsEditing] = useState(false) const { activeWorkflowId } = useWorkflowRegistry() const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) @@ -128,11 +128,7 @@ export function CredentialSelector({ return '' }, [selectedCredentialSet, isForeignCredentialSet, selectedCredential, isForeign]) - useEffect(() => { - if (!isEditing) { - setInputValue(resolvedLabel) - } - }, [resolvedLabel, isEditing]) + const displayValue = isEditing ? editingValue : resolvedLabel const invalidSelection = !isPreview && @@ -295,7 +291,7 @@ export function CredentialSelector({ const selectedCredentialProvider = selectedCredential?.provider ?? provider const overlayContent = useMemo(() => { - if (!inputValue) return null + if (!displayValue) return null if (isCredentialSetSelected && selectedCredentialSet) { return ( @@ -303,7 +299,7 @@ export function CredentialSelector({
- {inputValue} + {displayValue} ) } @@ -313,12 +309,12 @@ export function CredentialSelector({
{getProviderIcon(selectedCredentialProvider)}
- {inputValue} + {displayValue} ) }, [ getProviderIcon, - inputValue, + displayValue, selectedCredentialProvider, isCredentialSetSelected, selectedCredentialSet, @@ -335,7 +331,6 @@ export function CredentialSelector({ const credentialSetId = value.slice(CREDENTIAL_SET.PREFIX.length) const matchedSet = credentialSets.find((cs) => cs.id === credentialSetId) if (matchedSet) { - setInputValue(matchedSet.name) handleCredentialSetSelect(credentialSetId) return } @@ -343,13 +338,12 @@ export function CredentialSelector({ const matchedCred = credentials.find((c) => c.id === value) if (matchedCred) { - setInputValue(matchedCred.name) handleSelect(value) return } setIsEditing(true) - setInputValue(value) + setEditingValue(value) }, [credentials, credentialSets, handleAddCredential, handleSelect, handleCredentialSetSelect] ) @@ -359,7 +353,7 @@ export function CredentialSelector({ +/** Shared style for dashed divider lines */ +const DASHED_DIVIDER_STYLE = { + backgroundImage: + 'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)', +} as const + /** * Icon component for rendering block icons. * @@ -92,25 +98,19 @@ export function Editor() { const blockConfig = currentBlock ? getBlock(currentBlock.type) : null const title = currentBlock?.name || 'Editor' - // Check if selected block is a subflow (loop or parallel) const isSubflow = currentBlock && (currentBlock.type === 'loop' || currentBlock.type === 'parallel') - // Get subflow display properties from configs const subflowConfig = isSubflow ? (currentBlock.type === 'loop' ? LoopTool : ParallelTool) : null - // Check if selected block is a workflow block const isWorkflowBlock = currentBlock && (currentBlock.type === 'workflow' || currentBlock.type === 'workflow_input') - // Get workspace ID from params const params = useParams() const workspaceId = params.workspaceId as string - // Refs for resize functionality const subBlocksRef = useRef(null) - // Get user permissions const userPermissions = useUserPermissionsContext() // Check if block is locked (or inside a locked container) and compute edit permission @@ -121,10 +121,8 @@ export function Editor() { const isLocked = (currentBlock?.locked ?? false) || isParentLocked const canEditBlock = userPermissions.canEdit && !isLocked - // Get active workflow ID const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) - // Get block properties (advanced/trigger modes) const { advancedMode, triggerMode } = useEditorBlockProperties( currentBlockId, currentWorkflow.isSnapshotView @@ -156,20 +154,17 @@ export function Editor() { [subBlocksForCanonical] ) const canonicalModeOverrides = currentBlock?.data?.canonicalModes - const advancedValuesPresent = hasAdvancedValues( - subBlocksForCanonical, - blockSubBlockValues, - canonicalIndex + const advancedValuesPresent = useMemo( + () => hasAdvancedValues(subBlocksForCanonical, blockSubBlockValues, canonicalIndex), + [subBlocksForCanonical, blockSubBlockValues, canonicalIndex] ) const displayAdvancedOptions = canEditBlock ? advancedMode : advancedMode || advancedValuesPresent const hasAdvancedOnlyFields = useMemo(() => { for (const subBlock of subBlocksForCanonical) { - // Must be standalone advanced (mode: 'advanced' without canonicalParamId) if (subBlock.mode !== 'advanced') continue if (canonicalIndex.canonicalIdBySubBlockId[subBlock.id]) continue - // Check condition - skip if condition not met for current values if ( subBlock.condition && !evaluateSubBlockCondition(subBlock.condition, blockSubBlockValues) @@ -182,7 +177,6 @@ export function Editor() { return false }, [subBlocksForCanonical, canonicalIndex.canonicalIdBySubBlockId, blockSubBlockValues]) - // Get subblock layout using custom hook const { subBlocks, stateToUse: subBlockState } = useEditorSubblockLayout( blockConfig || ({} as any), currentBlockId || '', @@ -215,15 +209,12 @@ export function Editor() { return { regularSubBlocks: regular, advancedOnlySubBlocks: advancedOnly } }, [subBlocks, canonicalIndex.canonicalIdBySubBlockId]) - // Get block connections const { incomingConnections, hasIncomingConnections } = useBlockConnections(currentBlockId || '') - // Connections resize hook const { handleMouseDown: handleConnectionsResizeMouseDown, isResizing } = useConnectionsResize({ subBlocksRef, }) - // Collaborative actions const { collaborativeSetBlockCanonicalMode, collaborativeUpdateBlockName, @@ -231,16 +222,22 @@ export function Editor() { collaborativeBatchToggleLocked, } = useCollaborativeWorkflow() - // Advanced mode toggle handler const handleToggleAdvancedMode = useCallback(() => { if (!currentBlockId || !canEditBlock) return collaborativeToggleBlockAdvancedMode(currentBlockId) }, [currentBlockId, canEditBlock, collaborativeToggleBlockAdvancedMode]) - // Rename state const [isRenaming, setIsRenaming] = useState(false) const [editedName, setEditedName] = useState('') - const nameInputRef = useRef(null) + + /** + * Ref callback that auto-selects the input text when mounted. + */ + const nameInputRefCallback = useCallback((element: HTMLInputElement | null) => { + if (element) { + element.select() + } + }, []) /** * Handles starting the rename process. @@ -261,7 +258,6 @@ export function Editor() { if (trimmedName && trimmedName !== currentBlock?.name) { const result = collaborativeUpdateBlockName(currentBlockId, trimmedName) if (!result.success) { - // Keep rename mode open on error so user can correct the name return } } @@ -276,14 +272,6 @@ export function Editor() { setEditedName('') }, []) - // Focus input when entering rename mode - useEffect(() => { - if (isRenaming && nameInputRef.current) { - nameInputRef.current.select() - } - }, [isRenaming]) - - // Trigger rename mode when signaled from context menu useEffect(() => { if (shouldFocusRename && currentBlock) { handleStartRename() @@ -294,17 +282,13 @@ export function Editor() { /** * Handles opening documentation link in a new secure tab. */ - const handleOpenDocs = () => { + const handleOpenDocs = useCallback(() => { const docsLink = isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink - if (docsLink) { - window.open(docsLink, '_blank', 'noopener,noreferrer') - } - } + window.open(docsLink || 'https://docs.sim.ai/quick-reference', '_blank', 'noopener,noreferrer') + }, [isSubflow, subflowConfig?.docsLink, blockConfig?.docsLink]) - // Get child workflow ID for workflow blocks const childWorkflowId = isWorkflowBlock ? blockSubBlockValues?.workflowId : null - // Fetch child workflow state for preview (only for workflow blocks with a selected workflow) const { data: childWorkflowState, isLoading: isLoadingChildWorkflow } = useWorkflowState(childWorkflowId) @@ -317,7 +301,6 @@ export function Editor() { } }, [childWorkflowId, workspaceId]) - // Determine if connections are at minimum height (collapsed state) const isConnectionsAtMinHeight = connectionsHeight <= 35 return ( @@ -338,7 +321,7 @@ export function Editor() { )} {isRenaming ? ( setEditedName(e.target.value)} @@ -439,23 +422,21 @@ export function Editor() { )} */} - {currentBlock && (isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink) && ( - - - - - -

Open docs

-
-
- )} + + + + + +

Open docs

+
+
@@ -535,13 +516,7 @@ export function Editor() {
-
+
)} @@ -606,13 +581,7 @@ export function Editor() { /> {showDivider && (
-
+
)}
@@ -621,13 +590,7 @@ export function Editor() { {hasAdvancedOnlyFields && canEditBlock && (
-
+
-
+
)} @@ -670,13 +627,7 @@ export function Editor() { /> {index < advancedOnlySubBlocks.length - 1 && (
-
+
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index abe60c6a4..540f97bba 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx @@ -24,6 +24,7 @@ import { Tooltip, } from '@/components/emcn' import { getEnv, isTruthy } from '@/lib/core/config/env' +import { formatDuration } from '@/lib/core/utils/formatting' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils' import { @@ -43,7 +44,6 @@ import { type EntryNode, type ExecutionGroup, flattenBlockEntriesOnly, - formatDuration, getBlockColor, getBlockIcon, groupEntriesByExecution, @@ -128,7 +128,7 @@ const BlockRow = memo(function BlockRow({
@@ -201,7 +201,7 @@ const IterationNodeRow = memo(function IterationNodeRow({
@@ -314,7 +314,7 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts index 6dbc15770..18b8cfef6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/utils.ts @@ -53,17 +53,6 @@ export function getBlockColor(blockType: string): string { return '#6b7280' } -/** - * Formats duration from milliseconds to readable format - */ -export function formatDuration(ms?: number): string { - if (ms === undefined || ms === null) return '-' - if (ms < 1000) { - return `${Math.round(ms)}ms` - } - return `${(ms / 1000).toFixed(2)}s` -} - /** * Determines if a keyboard event originated from a text-editable element */ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/training-modal/training-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/training-modal/training-modal.tsx index cbdac251f..3003d4acd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/training-modal/training-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/training-modal/training-modal.tsx @@ -30,6 +30,7 @@ import { Textarea, } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' +import { formatDuration } from '@/lib/core/utils/formatting' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { formatEditSequence } from '@/lib/workflows/training/compute-edit-sequence' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow' @@ -575,7 +576,9 @@ export function TrainingModal() { Duration:{' '} {dataset.metadata?.duration - ? `${(dataset.metadata.duration / 1000).toFixed(1)}s` + ? formatDuration(dataset.metadata.duration, { + precision: 1, + }) : 'N/A'}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils.ts index 797c7f2c9..eb76077fc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils.ts @@ -70,18 +70,3 @@ export function filterProtectedBlocks( 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 -): boolean { - return blockIds.some((id) => isBlockProtected(id, blocks)) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx index a1fae5b1a..ce6154939 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx @@ -246,7 +246,6 @@ export function CredentialSets() { setNewSetDescription('') setNewSetProvider('google-email') - // Open detail view for the newly created group if (result?.credentialSet) { setViewingSet(result.credentialSet) } @@ -336,7 +335,6 @@ export function CredentialSets() { email, }) - // Start 60s cooldown setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 })) const interval = setInterval(() => { setResendCooldowns((prev) => { @@ -393,7 +391,6 @@ export function CredentialSets() { return } - // All hooks must be called before any early returns const activeMemberships = useMemo( () => memberships.filter((m) => m.status === 'active'), [memberships] @@ -447,7 +444,6 @@ export function CredentialSets() {
- {/* Group Info */}
@@ -471,7 +467,6 @@ export function CredentialSets() {
- {/* Invite Section - Email Tags Input */}
{emailError}

}
- {/* Members List - styled like team members */}

Members

@@ -519,7 +513,6 @@ export function CredentialSets() {

) : (
- {/* Active Members */} {activeMembers.map((member) => { const name = member.userName || 'Unknown' const avatarInitial = name.charAt(0).toUpperCase() @@ -572,7 +565,6 @@ export function CredentialSets() { ) })} - {/* Pending Invitations */} {pendingInvitations.map((invitation) => { const email = invitation.email || 'Unknown' const emailPrefix = email.split('@')[0] @@ -641,7 +633,6 @@ export function CredentialSets() {
- {/* Footer Actions */}
- {/* Create Polling Group Modal */} Create Polling Group @@ -895,7 +885,6 @@ export function CredentialSets() { - {/* Leave Confirmation Modal */} setLeavingMembership(null)}> Leave Polling Group @@ -923,7 +912,6 @@ export function CredentialSets() { - {/* Delete Confirmation Modal */} setDeletingSet(null)}> Delete Polling Group diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts index e2241137f..db87eaf39 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts @@ -1,4 +1,3 @@ -export { AccessControl } from './access-control/access-control' export { ApiKeys } from './api-keys/api-keys' export { BYOK } from './byok/byok' export { Copilot } from './copilot/copilot' @@ -10,7 +9,6 @@ export { Files as FileUploads } from './files/files' export { General } from './general/general' export { Integrations } from './integrations/integrations' export { MCP } from './mcp/mcp' -export { SSO } from './sso/sso' export { Subscription } from './subscription/subscription' export { TeamManagement } from './team-management/team-management' export { WorkflowMcpServers } from './workflow-mcp-servers/workflow-mcp-servers' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx index d4103702b..d25865a74 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx @@ -407,14 +407,12 @@ export function MCP({ initialServerId }: MCPProps) { const [urlScrollLeft, setUrlScrollLeft] = useState(0) const [headerScrollLeft, setHeaderScrollLeft] = useState>({}) - // Auto-select server when initialServerId is provided useEffect(() => { if (initialServerId && servers.some((s) => s.id === initialServerId)) { setSelectedServerId(initialServerId) } }, [initialServerId, servers]) - // Force refresh tools when entering server detail view to detect stale schemas useEffect(() => { if (selectedServerId) { forceRefreshTools(workspaceId) @@ -675,6 +673,7 @@ export function MCP({ initialServerId }: MCPProps) { /** * Opens the detail view for a specific server. + * Note: Tool refresh is handled by the useEffect that watches selectedServerId */ const handleViewDetails = useCallback((serverId: string) => { setSelectedServerId(serverId) @@ -717,7 +716,6 @@ export function MCP({ initialServerId }: MCPProps) { `Refreshed MCP server: ${serverId}, workflows updated: ${result.workflowsUpdated}` ) - // If the active workflow was updated, reload its subblock values from DB const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId if (activeWorkflowId && result.updatedWorkflowIds?.includes(activeWorkflowId)) { logger.info(`Active workflow ${activeWorkflowId} was updated, reloading subblock values`) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx index d2a72a998..b9a3dc5be 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx @@ -41,7 +41,6 @@ import { getEnv, isTruthy } from '@/lib/core/config/env' import { isHosted } from '@/lib/core/config/feature-flags' import { getUserRole } from '@/lib/workspaces/organization' import { - AccessControl, ApiKeys, BYOK, Copilot, @@ -53,16 +52,18 @@ import { General, Integrations, MCP, - SSO, Subscription, TeamManagement, WorkflowMcpServers, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components' import { TemplateProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile' +import { AccessControl } from '@/ee/access-control/components/access-control' +import { SSO } from '@/ee/sso/components/sso-settings' +import { ssoKeys, useSSOProviders } from '@/ee/sso/hooks/sso' import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general-settings' import { organizationKeys, useOrganizations } from '@/hooks/queries/organization' -import { ssoKeys, useSSOProviders } from '@/hooks/queries/sso' import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription' +import { useSuperUserStatus } from '@/hooks/queries/user-profile' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsModalStore } from '@/stores/modals/settings/store' @@ -204,13 +205,13 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { const [activeSection, setActiveSection] = useState('general') const { initialSection, mcpServerId, clearInitialState } = useSettingsModalStore() const [pendingMcpServerId, setPendingMcpServerId] = useState(null) - const [isSuperUser, setIsSuperUser] = useState(false) const { data: session } = useSession() const queryClient = useQueryClient() const { data: organizationsData } = useOrganizations() const { data: generalSettings } = useGeneralSettings() const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled }) const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders() + const { data: superUserData } = useSuperUserStatus(session?.user?.id) const activeOrganization = organizationsData?.activeOrganization const { config: permissionConfig } = usePermissionConfig() @@ -229,22 +230,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { const hasEnterprisePlan = subscriptionStatus.isEnterprise const hasOrganization = !!activeOrganization?.id - // Fetch superuser status - useEffect(() => { - const fetchSuperUserStatus = async () => { - if (!userId) return - try { - const response = await fetch('/api/user/super-user') - if (response.ok) { - const data = await response.json() - setIsSuperUser(data.isSuperUser) - } - } catch { - setIsSuperUser(false) - } - } - fetchSuperUserStatus() - }, [userId]) + const isSuperUser = superUserData?.isSuperUser ?? false // Memoize SSO provider ownership check const isSSOProviderOwner = useMemo(() => { @@ -328,7 +314,13 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { generalSettings?.superUserModeEnabled, ]) - // Memoized callbacks to prevent infinite loops in child components + const effectiveActiveSection = useMemo(() => { + if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) { + return 'general' + } + return activeSection + }, [activeSection]) + const registerEnvironmentBeforeLeaveHandler = useCallback( (handler: (onProceed: () => void) => void) => { environmentBeforeLeaveHandler.current = handler @@ -342,19 +334,18 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { const handleSectionChange = useCallback( (sectionId: SettingsSection) => { - if (sectionId === activeSection) return + if (sectionId === effectiveActiveSection) return - if (activeSection === 'environment' && environmentBeforeLeaveHandler.current) { + if (effectiveActiveSection === 'environment' && environmentBeforeLeaveHandler.current) { environmentBeforeLeaveHandler.current(() => setActiveSection(sectionId)) return } setActiveSection(sectionId) }, - [activeSection] + [effectiveActiveSection] ) - // Apply initial section from store when modal opens useEffect(() => { if (open && initialSection) { setActiveSection(initialSection) @@ -365,7 +356,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { } }, [open, initialSection, mcpServerId, clearInitialState]) - // Clear pending server ID when section changes away from MCP useEffect(() => { if (activeSection !== 'mcp') { setPendingMcpServerId(null) @@ -391,14 +381,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { } }, [onOpenChange]) - // Redirect away from billing tabs if billing is disabled - useEffect(() => { - if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) { - setActiveSection('general') - } - }, [activeSection]) - - // Prefetch functions for React Query const prefetchGeneral = () => { queryClient.prefetchQuery({ queryKey: generalSettingsKeys.settings(), @@ -489,9 +471,17 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { // Handle dialog close - delegate to environment component if it's active const handleDialogOpenChange = (newOpen: boolean) => { - if (!newOpen && activeSection === 'environment' && environmentBeforeLeaveHandler.current) { + if ( + !newOpen && + effectiveActiveSection === 'environment' && + environmentBeforeLeaveHandler.current + ) { environmentBeforeLeaveHandler.current(() => onOpenChange(false)) - } else if (!newOpen && activeSection === 'integrations' && integrationsCloseHandler.current) { + } else if ( + !newOpen && + effectiveActiveSection === 'integrations' && + integrationsCloseHandler.current + ) { integrationsCloseHandler.current(newOpen) } else { onOpenChange(newOpen) @@ -522,7 +512,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { {sectionItems.map((item) => ( } onMouseEnter={() => handlePrefetch(item.id)} onClick={() => handleSectionChange(item.id)} @@ -538,35 +528,36 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { - {navigationItems.find((item) => item.id === activeSection)?.label || activeSection} + {navigationItems.find((item) => item.id === effectiveActiveSection)?.label || + effectiveActiveSection} - {activeSection === 'general' && } - {activeSection === 'environment' && ( + {effectiveActiveSection === 'general' && } + {effectiveActiveSection === 'environment' && ( )} - {activeSection === 'template-profile' && } - {activeSection === 'integrations' && ( + {effectiveActiveSection === 'template-profile' && } + {effectiveActiveSection === 'integrations' && ( )} - {activeSection === 'credential-sets' && } - {activeSection === 'access-control' && } - {activeSection === 'apikeys' && } - {activeSection === 'files' && } - {isBillingEnabled && activeSection === 'subscription' && } - {isBillingEnabled && activeSection === 'team' && } - {activeSection === 'sso' && } - {activeSection === 'byok' && } - {activeSection === 'copilot' && } - {activeSection === 'mcp' && } - {activeSection === 'custom-tools' && } - {activeSection === 'workflow-mcp-servers' && } - {activeSection === 'debug' && } + {effectiveActiveSection === 'credential-sets' && } + {effectiveActiveSection === 'access-control' && } + {effectiveActiveSection === 'apikeys' && } + {effectiveActiveSection === 'files' && } + {isBillingEnabled && effectiveActiveSection === 'subscription' && } + {isBillingEnabled && effectiveActiveSection === 'team' && } + {effectiveActiveSection === 'sso' && } + {effectiveActiveSection === 'byok' && } + {effectiveActiveSection === 'copilot' && } + {effectiveActiveSection === 'mcp' && } + {effectiveActiveSection === 'custom-tools' && } + {effectiveActiveSection === 'workflow-mcp-servers' && } + {effectiveActiveSection === 'debug' && } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx index 7cca37364..989532c28 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx @@ -231,6 +231,8 @@ export function FolderItem({ const isFolderSelected = store.selectedFolders.has(folder.id) if (!isFolderSelected) { + // Replace selection with just this folder (Finder/Explorer pattern) + store.clearAllSelection() store.selectFolder(folder.id) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx index 3c099da60..6963464d4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx @@ -189,6 +189,9 @@ export function WorkflowItem({ const isCurrentlySelected = store.selectedWorkflows.has(workflow.id) if (!isCurrentlySelected) { + // Replace selection with just this item (Finder/Explorer pattern) + // This clears both workflow and folder selections + store.clearAllSelection() store.selectWorkflow(workflow.id) } diff --git a/apps/sim/background/workspace-notification-delivery.ts b/apps/sim/background/workspace-notification-delivery.ts index d5dbf3a92..0d9a8254b 100644 --- a/apps/sim/background/workspace-notification-delivery.ts +++ b/apps/sim/background/workspace-notification-delivery.ts @@ -19,6 +19,7 @@ import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { RateLimiter } from '@/lib/core/rate-limiter' import { decryptSecret } from '@/lib/core/security/encryption' +import { formatDuration } from '@/lib/core/utils/formatting' import { getBaseUrl } from '@/lib/core/utils/urls' import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types' import { sendEmail } from '@/lib/messaging/email/mailer' @@ -227,12 +228,6 @@ async function deliverWebhook( } } -function formatDuration(ms: number): string { - if (ms < 1000) return `${ms}ms` - if (ms < 60000) return `${(ms / 1000).toFixed(1)}s` - return `${(ms / 60000).toFixed(1)}m` -} - function formatCost(cost?: Record): string { if (!cost?.total) return 'N/A' const total = cost.total as number @@ -302,7 +297,7 @@ async function deliverEmail( workflowName: payload.data.workflowName || 'Unknown Workflow', status: payload.data.status, trigger: payload.data.trigger, - duration: formatDuration(payload.data.totalDurationMs), + duration: formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-', cost: formatCost(payload.data.cost), logUrl, alertReason, @@ -315,7 +310,7 @@ async function deliverEmail( to: subscription.emailRecipients, subject, html, - text: `${subject}\n${alertReason ? `\nReason: ${alertReason}\n` : ''}\nWorkflow: ${payload.data.workflowName}\nStatus: ${statusText}\nTrigger: ${payload.data.trigger}\nDuration: ${formatDuration(payload.data.totalDurationMs)}\nCost: ${formatCost(payload.data.cost)}\n\nView Log: ${logUrl}${includedDataText}`, + text: `${subject}\n${alertReason ? `\nReason: ${alertReason}\n` : ''}\nWorkflow: ${payload.data.workflowName}\nStatus: ${statusText}\nTrigger: ${payload.data.trigger}\nDuration: ${formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-'}\nCost: ${formatCost(payload.data.cost)}\n\nView Log: ${logUrl}${includedDataText}`, emailType: 'notifications', }) @@ -373,7 +368,10 @@ async function deliverSlack( fields: [ { type: 'mrkdwn', text: `*Status:*\n${payload.data.status}` }, { type: 'mrkdwn', text: `*Trigger:*\n${payload.data.trigger}` }, - { type: 'mrkdwn', text: `*Duration:*\n${formatDuration(payload.data.totalDurationMs)}` }, + { + type: 'mrkdwn', + text: `*Duration:*\n${formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-'}`, + }, { type: 'mrkdwn', text: `*Cost:*\n${formatCost(payload.data.cost)}` }, ], }, diff --git a/apps/sim/blocks/blocks/enrich.ts b/apps/sim/blocks/blocks/enrich.ts new file mode 100644 index 000000000..40081c16f --- /dev/null +++ b/apps/sim/blocks/blocks/enrich.ts @@ -0,0 +1,625 @@ +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 = { ...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' }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 225a3b224..4fbaf2766 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -26,6 +26,7 @@ import { DuckDuckGoBlock } from '@/blocks/blocks/duckduckgo' import { DynamoDBBlock } from '@/blocks/blocks/dynamodb' import { ElasticsearchBlock } from '@/blocks/blocks/elasticsearch' import { ElevenLabsBlock } from '@/blocks/blocks/elevenlabs' +import { EnrichBlock } from '@/blocks/blocks/enrich' import { EvaluatorBlock } from '@/blocks/blocks/evaluator' import { ExaBlock } from '@/blocks/blocks/exa' import { FileBlock, FileV2Block } from '@/blocks/blocks/file' @@ -188,6 +189,7 @@ export const registry: Record = { dynamodb: DynamoDBBlock, elasticsearch: ElasticsearchBlock, elevenlabs: ElevenLabsBlock, + enrich: EnrichBlock, evaluator: EvaluatorBlock, exa: ExaBlock, file: FileBlock, diff --git a/apps/sim/components/emcn/components/popover/popover.tsx b/apps/sim/components/emcn/components/popover/popover.tsx index d84200d21..e925b3d19 100644 --- a/apps/sim/components/emcn/components/popover/popover.tsx +++ b/apps/sim/components/emcn/components/popover/popover.tsx @@ -260,6 +260,9 @@ const Popover: React.FC = ({ setIsKeyboardNav(false) setSelectedIndex(-1) registeredItemsRef.current = [] + } else { + // Reset hover state when opening to prevent stale submenu from previous menu + setLastHoveredItem(null) } }, [open]) diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index a31b85585..2e1e48778 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -5421,3 +5421,18 @@ z' ) } + +export function EnrichSoIcon(props: SVGProps) { + return ( + + + + + ) +} diff --git a/apps/sim/components/ui/tool-call.tsx b/apps/sim/components/ui/tool-call.tsx index b6d76ca7e..0d7d2ece2 100644 --- a/apps/sim/components/ui/tool-call.tsx +++ b/apps/sim/components/ui/tool-call.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import type { ToolCallGroup, ToolCallState } from '@/lib/copilot/types' import { cn } from '@/lib/core/utils/cn' +import { formatDuration } from '@/lib/core/utils/formatting' interface ToolCallProps { toolCall: ToolCallState @@ -225,11 +226,6 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp const isError = toolCall.state === 'error' const isAborted = toolCall.state === 'aborted' - const formatDuration = (duration?: number) => { - if (!duration) return '' - return duration < 1000 ? `${duration}ms` : `${(duration / 1000).toFixed(1)}s` - } - return (
- {formatDuration(toolCall.duration)} + {toolCall.duration ? formatDuration(toolCall.duration, { precision: 1 }) : ''} )}
diff --git a/apps/sim/ee/LICENSE b/apps/sim/ee/LICENSE new file mode 100644 index 000000000..ba5405dbf --- /dev/null +++ b/apps/sim/ee/LICENSE @@ -0,0 +1,43 @@ +Sim Enterprise License + +Copyright (c) 2025-present Sim Studio, Inc. + +This software and associated documentation files (the "Software") are licensed +under the following terms: + +1. LICENSE GRANT + + Subject to the terms of this license, Sim Studio, Inc. grants you a limited, + non-exclusive, non-transferable license to use the Software for: + + - Development, testing, and evaluation purposes + - Internal non-production use + + Production use of the Software requires a valid Sim Enterprise subscription. + +2. RESTRICTIONS + + You may not: + + - Use the Software in production without a valid Enterprise subscription + - Modify, adapt, or create derivative works of the Software + - Redistribute, sublicense, or transfer the Software + - Remove or alter any proprietary notices in the Software + +3. ENTERPRISE SUBSCRIPTION + + Production deployment of enterprise features requires an active Sim Enterprise + subscription. Contact sales@simstudio.ai for licensing information. + +4. DISCLAIMER + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + +5. LIMITATION OF LIABILITY + + IN NO EVENT SHALL SIM STUDIO, INC. BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY ARISING FROM THE USE OF THE SOFTWARE. + +For questions about enterprise licensing, contact: sales@simstudio.ai diff --git a/apps/sim/ee/README.md b/apps/sim/ee/README.md new file mode 100644 index 000000000..d9e91afaf --- /dev/null +++ b/apps/sim/ee/README.md @@ -0,0 +1,21 @@ +# Sim Enterprise Edition + +This directory contains enterprise features that require a Sim Enterprise subscription +for production use. + +## Features + +- **SSO (Single Sign-On)**: OIDC and SAML authentication integration +- **Access Control**: Permission groups for fine-grained user access management +- **Credential Sets**: Shared credential pools for email polling workflows + +## Licensing + +See [LICENSE](./LICENSE) for terms. Development and testing use is permitted. +Production deployment requires an active Enterprise subscription. + +## Architecture + +Enterprise features are imported directly throughout the codebase. The `ee/` directory +is required at build time. Feature visibility is controlled at runtime via environment +variables (e.g., `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED`). diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx similarity index 99% rename from apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx rename to apps/sim/ee/access-control/components/access-control.tsx index af7db3fcc..83f2f28dc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -29,7 +29,6 @@ import type { PermissionGroupConfig } from '@/lib/permission-groups/types' import { getUserColor } from '@/lib/workspaces/colors' import { getUserRole } from '@/lib/workspaces/organization' import { getAllBlocks } from '@/blocks' -import { useOrganization, useOrganizations } from '@/hooks/queries/organization' import { type PermissionGroup, useBulkAddPermissionGroupMembers, @@ -39,7 +38,8 @@ import { usePermissionGroups, useRemovePermissionGroupMember, useUpdatePermissionGroup, -} from '@/hooks/queries/permission-groups' +} from '@/ee/access-control/hooks/permission-groups' +import { useOrganization, useOrganizations } from '@/hooks/queries/organization' import { useSubscriptionData } from '@/hooks/queries/subscription' import { PROVIDER_DEFINITIONS } from '@/providers/models' import { getAllProviderIds } from '@/providers/utils' @@ -255,7 +255,6 @@ export function AccessControl() { queryEnabled ) - // Show loading while dependencies load, or while permission groups query is pending const isLoading = orgsLoading || subLoading || (queryEnabled && groupsLoading) const { data: organization } = useOrganization(activeOrganization?.id || '') @@ -410,10 +409,8 @@ export function AccessControl() { }, [viewingGroup, editingConfig]) const allBlocks = useMemo(() => { - // Filter out hidden blocks and start_trigger (which should never be disabled) const blocks = getAllBlocks().filter((b) => !b.hideFromToolbar && b.type !== 'start_trigger') return blocks.sort((a, b) => { - // Group by category: triggers first, then blocks, then tools const categoryOrder = { triggers: 0, blocks: 1, tools: 2 } const catA = categoryOrder[a.category] ?? 3 const catB = categoryOrder[b.category] ?? 3 @@ -555,10 +552,9 @@ export function AccessControl() { }, [viewingGroup, editingConfig, activeOrganization?.id, updatePermissionGroup]) const handleOpenAddMembersModal = useCallback(() => { - const existingMemberUserIds = new Set(members.map((m) => m.userId)) setSelectedMemberIds(new Set()) setShowAddMembersModal(true) - }, [members]) + }, []) const handleAddSelectedMembers = useCallback(async () => { if (!viewingGroup || selectedMemberIds.size === 0) return @@ -891,7 +887,6 @@ export function AccessControl() { prev ? { ...prev, - // When deselecting all, keep start_trigger allowed (it should never be disabled) allowedIntegrations: allAllowed ? ['start_trigger'] : null, } : prev diff --git a/apps/sim/hooks/queries/permission-groups.ts b/apps/sim/ee/access-control/hooks/permission-groups.ts similarity index 99% rename from apps/sim/hooks/queries/permission-groups.ts rename to apps/sim/ee/access-control/hooks/permission-groups.ts index 6832d5188..91f838ced 100644 --- a/apps/sim/hooks/queries/permission-groups.ts +++ b/apps/sim/ee/access-control/hooks/permission-groups.ts @@ -1,3 +1,5 @@ +'use client' + import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { PermissionGroupConfig } from '@/lib/permission-groups/types' import { fetchJson } from '@/hooks/selectors/helpers' diff --git a/apps/sim/executor/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts similarity index 100% rename from apps/sim/executor/utils/permission-check.ts rename to apps/sim/ee/access-control/utils/permission-check.ts diff --git a/apps/sim/app/chat/components/auth/sso/sso-auth.tsx b/apps/sim/ee/sso/components/sso-auth.tsx similarity index 100% rename from apps/sim/app/chat/components/auth/sso/sso-auth.tsx rename to apps/sim/ee/sso/components/sso-auth.tsx diff --git a/apps/sim/app/(auth)/sso/sso-form.tsx b/apps/sim/ee/sso/components/sso-form.tsx similarity index 100% rename from apps/sim/app/(auth)/sso/sso-form.tsx rename to apps/sim/ee/sso/components/sso-form.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/sso/sso.tsx b/apps/sim/ee/sso/components/sso-settings.tsx similarity index 97% rename from apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/sso/sso.tsx rename to apps/sim/ee/sso/components/sso-settings.tsx index 2657c8204..a43e15ff3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/sso/sso.tsx +++ b/apps/sim/ee/sso/components/sso-settings.tsx @@ -11,55 +11,13 @@ import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { cn } from '@/lib/core/utils/cn' import { getBaseUrl } from '@/lib/core/utils/urls' import { getUserRole } from '@/lib/workspaces/organization/utils' +import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants' +import { useConfigureSSO, useSSOProviders } from '@/ee/sso/hooks/sso' import { useOrganizations } from '@/hooks/queries/organization' -import { useConfigureSSO, useSSOProviders } from '@/hooks/queries/sso' import { useSubscriptionData } from '@/hooks/queries/subscription' const logger = createLogger('SSO') -const TRUSTED_SSO_PROVIDERS = [ - 'okta', - 'okta-saml', - 'okta-prod', - 'okta-dev', - 'okta-staging', - 'okta-test', - 'azure-ad', - 'azure-active-directory', - 'azure-corp', - 'azure-enterprise', - 'adfs', - 'adfs-company', - 'adfs-corp', - 'adfs-enterprise', - 'auth0', - 'auth0-prod', - 'auth0-dev', - 'auth0-staging', - 'onelogin', - 'onelogin-prod', - 'onelogin-corp', - 'jumpcloud', - 'jumpcloud-prod', - 'jumpcloud-corp', - 'ping-identity', - 'ping-federate', - 'pingone', - 'shibboleth', - 'shibboleth-idp', - 'google-workspace', - 'google-sso', - 'saml', - 'saml2', - 'saml-sso', - 'oidc', - 'oidc-sso', - 'openid-connect', - 'custom-sso', - 'enterprise-sso', - 'company-sso', -] - interface SSOProvider { id: string providerId: string @@ -565,7 +523,7 @@ export function SSO() { handleInputChange('providerId', value)} - options={TRUSTED_SSO_PROVIDERS.map((id) => ({ + options={SSO_TRUSTED_PROVIDERS.map((id) => ({ label: id, value: id, }))} diff --git a/apps/sim/lib/auth/sso/constants.ts b/apps/sim/ee/sso/constants.ts similarity index 85% rename from apps/sim/lib/auth/sso/constants.ts rename to apps/sim/ee/sso/constants.ts index ca246f8cf..67cfee94f 100644 --- a/apps/sim/lib/auth/sso/constants.ts +++ b/apps/sim/ee/sso/constants.ts @@ -1,3 +1,7 @@ +/** + * List of trusted SSO provider identifiers. + * Used for validation and autocomplete in SSO configuration. + */ export const SSO_TRUSTED_PROVIDERS = [ 'okta', 'okta-saml', diff --git a/apps/sim/hooks/queries/sso.ts b/apps/sim/ee/sso/hooks/sso.ts similarity index 69% rename from apps/sim/hooks/queries/sso.ts rename to apps/sim/ee/sso/hooks/sso.ts index 7c5c769ab..2dfa1592e 100644 --- a/apps/sim/hooks/queries/sso.ts +++ b/apps/sim/ee/sso/hooks/sso.ts @@ -1,3 +1,5 @@ +'use client' + import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { organizationKeys } from '@/hooks/queries/organization' @@ -75,39 +77,3 @@ export function useConfigureSSO() { }, }) } - -/** - * Delete SSO provider mutation - */ -interface DeleteSSOParams { - providerId: string - orgId?: string -} - -export function useDeleteSSO() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async ({ providerId }: DeleteSSOParams) => { - const response = await fetch(`/api/auth/sso/providers/${providerId}`, { - method: 'DELETE', - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.message || 'Failed to delete SSO provider') - } - - return response.json() - }, - onSuccess: (_data, variables) => { - queryClient.invalidateQueries({ queryKey: ssoKeys.providers() }) - - if (variables.orgId) { - queryClient.invalidateQueries({ - queryKey: organizationKeys.detail(variables.orgId), - }) - } - }, - }) -} diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index d17da0e7c..59b08e4a9 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -5,6 +5,7 @@ import { hydrateUserFilesWithBase64, } from '@/lib/uploads/utils/user-file-base64.server' import { sanitizeInputFormat, sanitizeTools } from '@/lib/workflows/comparison/normalize' +import { validateBlockType } from '@/ee/access-control/utils/permission-check' import { BlockType, buildResumeApiUrl, @@ -31,7 +32,6 @@ import { streamingResponseFormatProcessor } from '@/executor/utils' import { buildBlockExecutionError, normalizeError } from '@/executor/utils/errors' import { isJSONString } from '@/executor/utils/json' import { filterOutputForLog } from '@/executor/utils/output-filter' -import { validateBlockType } from '@/executor/utils/permission-check' import type { VariableResolver } from '@/executor/variables/resolver' import type { SerializedBlock } from '@/serializer/types' import type { SubflowType } from '@/stores/workflows/workflow/types' diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 007833d9c..40c7b9ba8 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -6,6 +6,12 @@ import { createMcpToolId } from '@/lib/mcp/utils' import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { getAllBlocks } from '@/blocks' import type { BlockOutput } from '@/blocks/types' +import { + validateBlockType, + validateCustomToolsAllowed, + validateMcpToolsAllowed, + validateModelProvider, +} from '@/ee/access-control/utils/permission-check' import { AGENT, BlockType, DEFAULTS, REFERENCE, stripCustomToolPrefix } from '@/executor/constants' import { memoryService } from '@/executor/handlers/agent/memory' import type { @@ -18,12 +24,6 @@ import type { BlockHandler, ExecutionContext, StreamingExecution } from '@/execu import { collectBlockData } from '@/executor/utils/block-data' import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http' import { stringifyJSON } from '@/executor/utils/json' -import { - validateBlockType, - validateCustomToolsAllowed, - validateMcpToolsAllowed, - validateModelProvider, -} from '@/executor/utils/permission-check' import { executeProviderRequest } from '@/providers' import { getProviderFromModel, transformBlockTool } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts index b383bdce0..3e95b2f85 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts @@ -4,11 +4,11 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' import type { BlockOutput } from '@/blocks/types' +import { validateModelProvider } from '@/ee/access-control/utils/permission-check' import { BlockType, DEFAULTS, EVALUATOR } from '@/executor/constants' import type { BlockHandler, ExecutionContext } from '@/executor/types' import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http' import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json' -import { validateModelProvider } from '@/executor/utils/permission-check' import { calculateCost, getProviderFromModel } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts index 12acf6c4c..766a4aac6 100644 --- a/apps/sim/executor/handlers/router/router-handler.ts +++ b/apps/sim/executor/handlers/router/router-handler.ts @@ -6,6 +6,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router' import type { BlockOutput } from '@/blocks/types' +import { validateModelProvider } from '@/ee/access-control/utils/permission-check' import { BlockType, DEFAULTS, @@ -15,7 +16,6 @@ import { } from '@/executor/constants' import type { BlockHandler, ExecutionContext } from '@/executor/types' import { buildAuthHeaders } from '@/executor/utils/http' -import { validateModelProvider } from '@/executor/utils/permission-check' import { calculateCost, getProviderFromModel } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' diff --git a/apps/sim/hooks/queries/credential-sets.ts b/apps/sim/hooks/queries/credential-sets.ts index 33da082f7..a18374b75 100644 --- a/apps/sim/hooks/queries/credential-sets.ts +++ b/apps/sim/hooks/queries/credential-sets.ts @@ -1,3 +1,5 @@ +'use client' + import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { fetchJson } from '@/hooks/selectors/helpers' diff --git a/apps/sim/hooks/queries/user-profile.ts b/apps/sim/hooks/queries/user-profile.ts index f01cbe585..0b3e048b8 100644 --- a/apps/sim/hooks/queries/user-profile.ts +++ b/apps/sim/hooks/queries/user-profile.ts @@ -9,6 +9,7 @@ const logger = createLogger('UserProfileQuery') export const userProfileKeys = { all: ['userProfile'] as const, profile: () => [...userProfileKeys.all, 'profile'] as const, + superUser: (userId?: string) => [...userProfileKeys.all, 'superUser', userId ?? ''] as const, } /** @@ -109,3 +110,37 @@ export function useUpdateUserProfile() { }, }) } + +/** + * Superuser status response type + */ +interface SuperUserStatus { + isSuperUser: boolean +} + +/** + * Fetch superuser status from API + */ +async function fetchSuperUserStatus(): Promise { + const response = await fetch('/api/user/super-user') + + if (!response.ok) { + return { isSuperUser: false } + } + + const data = await response.json() + return { isSuperUser: data.isSuperUser ?? false } +} + +/** + * Hook to fetch superuser status + * @param userId - User ID for cache isolation (required for proper per-user caching) + */ +export function useSuperUserStatus(userId?: string) { + return useQuery({ + queryKey: userProfileKeys.superUser(userId), + queryFn: fetchSuperUserStatus, + enabled: Boolean(userId), + staleTime: 5 * 60 * 1000, // 5 minutes - superuser status rarely changes + }) +} diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index 994656fdc..3c536caf5 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -1,3 +1,5 @@ +'use client' + import { useMemo } from 'react' import { getEnv, isTruthy } from '@/lib/core/config/env' import { isAccessControlEnabled, isHosted } from '@/lib/core/config/feature-flags' @@ -5,8 +7,8 @@ import { DEFAULT_PERMISSION_GROUP_CONFIG, type PermissionGroupConfig, } from '@/lib/permission-groups/types' +import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups' import { useOrganizations } from '@/hooks/queries/organization' -import { useUserPermissionConfig } from '@/hooks/queries/permission-groups' export interface PermissionConfigResult { config: PermissionGroupConfig diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 9241eaf09..d5ac1a8c2 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -59,8 +59,8 @@ import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' +import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants' import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous' -import { SSO_TRUSTED_PROVIDERS } from './sso/constants' const logger = createLogger('Auth') diff --git a/apps/sim/lib/billing/authorization.ts b/apps/sim/lib/billing/authorization.ts index 247d110ed..88e251197 100644 --- a/apps/sim/lib/billing/authorization.ts +++ b/apps/sim/lib/billing/authorization.ts @@ -1,20 +1,37 @@ import { db } from '@sim/db' import * as schema from '@sim/db/schema' +import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' +import { hasActiveSubscription } from '@/lib/billing' + +const logger = createLogger('BillingAuthorization') /** * Check if a user is authorized to manage billing for a given reference ID * Reference ID can be either a user ID (individual subscription) or organization ID (team subscription) + * + * This function also performs duplicate subscription validation for organizations: + * - Rejects if an organization already has an active subscription (prevents duplicates) + * - Personal subscriptions (referenceId === userId) skip this check to allow upgrades */ export async function authorizeSubscriptionReference( userId: string, referenceId: string ): Promise { - // User can always manage their own subscriptions + // User can always manage their own subscriptions (Pro upgrades, etc.) if (referenceId === userId) { return true } + // For organizations: check for existing active subscriptions to prevent duplicates + if (await hasActiveSubscription(referenceId)) { + logger.warn('Blocking checkout - active subscription already exists for organization', { + userId, + referenceId, + }) + return false + } + // Check if referenceId is an organizationId the user has admin rights to const members = await db .select() diff --git a/apps/sim/lib/billing/client/upgrade.ts b/apps/sim/lib/billing/client/upgrade.ts index acd7e651c..366807f5a 100644 --- a/apps/sim/lib/billing/client/upgrade.ts +++ b/apps/sim/lib/billing/client/upgrade.ts @@ -25,9 +25,11 @@ export function useSubscriptionUpgrade() { } let currentSubscriptionId: string | undefined + let allSubscriptions: any[] = [] try { const listResult = await client.subscription.list() - const activePersonalSub = listResult.data?.find( + allSubscriptions = listResult.data || [] + const activePersonalSub = allSubscriptions.find( (sub: any) => sub.status === 'active' && sub.referenceId === userId ) currentSubscriptionId = activePersonalSub?.id @@ -50,6 +52,25 @@ export function useSubscriptionUpgrade() { ) if (existingOrg) { + // Check if this org already has an active team subscription + const existingTeamSub = allSubscriptions.find( + (sub: any) => + sub.status === 'active' && + sub.referenceId === existingOrg.id && + (sub.plan === 'team' || sub.plan === 'enterprise') + ) + + if (existingTeamSub) { + logger.warn('Organization already has an active team subscription', { + userId, + organizationId: existingOrg.id, + existingSubscriptionId: existingTeamSub.id, + }) + throw new Error( + 'This organization already has an active team subscription. Please manage it from the billing settings.' + ) + } + logger.info('Using existing organization for team plan upgrade', { userId, organizationId: existingOrg.id, diff --git a/apps/sim/lib/billing/core/plan.ts b/apps/sim/lib/billing/core/plan.ts index 073a9a6a3..8af735542 100644 --- a/apps/sim/lib/billing/core/plan.ts +++ b/apps/sim/lib/billing/core/plan.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { member, subscription } from '@sim/db/schema' +import { member, organization, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, inArray } from 'drizzle-orm' import { checkEnterprisePlan, checkProPlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils' @@ -26,10 +26,22 @@ export async function getHighestPrioritySubscription(userId: string) { let orgSubs: typeof personalSubs = [] if (orgIds.length > 0) { - orgSubs = await db - .select() - .from(subscription) - .where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active'))) + // Verify orgs exist to filter out orphaned subscriptions + const existingOrgs = await db + .select({ id: organization.id }) + .from(organization) + .where(inArray(organization.id, orgIds)) + + const validOrgIds = existingOrgs.map((o) => o.id) + + if (validOrgIds.length > 0) { + orgSubs = await db + .select() + .from(subscription) + .where( + and(inArray(subscription.referenceId, validOrgIds), eq(subscription.status, 'active')) + ) + } } const allSubs = [...personalSubs, ...orgSubs] diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 2b287da4a..a42636762 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -25,6 +25,28 @@ const logger = createLogger('SubscriptionCore') export { getHighestPrioritySubscription } +/** + * Check if a referenceId (user ID or org ID) has an active subscription + * Used for duplicate subscription prevention + * + * Fails closed: returns true on error to prevent duplicate creation + */ +export async function hasActiveSubscription(referenceId: string): Promise { + try { + const [activeSub] = await db + .select({ id: subscription.id }) + .from(subscription) + .where(and(eq(subscription.referenceId, referenceId), eq(subscription.status, 'active'))) + .limit(1) + + return !!activeSub + } catch (error) { + logger.error('Error checking active subscription', { error, referenceId }) + // Fail closed: assume subscription exists to prevent duplicate creation + return true + } +} + /** * Check if user is on Pro plan (direct or via organization) */ diff --git a/apps/sim/lib/billing/index.ts b/apps/sim/lib/billing/index.ts index 9ec6f9cd6..189608979 100644 --- a/apps/sim/lib/billing/index.ts +++ b/apps/sim/lib/billing/index.ts @@ -11,6 +11,7 @@ export { getHighestPrioritySubscription as getActiveSubscription, getUserSubscriptionState as getSubscriptionState, hasAccessControlAccess, + hasActiveSubscription, hasCredentialSetsAccess, hasSSOAccess, isEnterpriseOrgAdminOrOwner, @@ -32,6 +33,11 @@ export { } from '@/lib/billing/core/usage' export * from '@/lib/billing/credits/balance' export * from '@/lib/billing/credits/purchase' +export { + blockOrgMembers, + getOrgMemberIds, + unblockOrgMembers, +} from '@/lib/billing/organizations/membership' export * from '@/lib/billing/subscriptions/utils' export { canEditUsageLimit as canEditLimit } from '@/lib/billing/subscriptions/utils' export * from '@/lib/billing/types' diff --git a/apps/sim/lib/billing/organization.ts b/apps/sim/lib/billing/organization.ts index eff6a03c0..a3b4e9818 100644 --- a/apps/sim/lib/billing/organization.ts +++ b/apps/sim/lib/billing/organization.ts @@ -8,6 +8,7 @@ import { } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' +import { hasActiveSubscription } from '@/lib/billing' import { getPlanPricing } from '@/lib/billing/core/billing' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' @@ -159,6 +160,16 @@ export async function ensureOrganizationForTeamSubscription( if (existingMembership.length > 0) { const membership = existingMembership[0] if (membership.role === 'owner' || membership.role === 'admin') { + // Check if org already has an active subscription (prevent duplicates) + if (await hasActiveSubscription(membership.organizationId)) { + logger.error('Organization already has an active subscription', { + userId, + organizationId: membership.organizationId, + newSubscriptionId: subscription.id, + }) + throw new Error('Organization already has an active subscription') + } + logger.info('User already owns/admins an org, using it', { userId, organizationId: membership.organizationId, diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index 4bfeff5ce..5fee8bb5c 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -15,13 +15,86 @@ import { userStats, } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, sql } from 'drizzle-orm' +import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' import { requireStripeClient } from '@/lib/billing/stripe-client' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' const logger = createLogger('OrganizationMembership') +export type BillingBlockReason = 'payment_failed' | 'dispute' + +/** + * Get all member user IDs for an organization + */ +export async function getOrgMemberIds(organizationId: string): Promise { + const members = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, organizationId)) + + return members.map((m) => m.userId) +} + +/** + * Block all members of an organization for billing reasons + * Returns the number of members actually blocked + * + * Reason priority: dispute > payment_failed + * A payment_failed block won't overwrite an existing dispute block + */ +export async function blockOrgMembers( + organizationId: string, + reason: BillingBlockReason +): Promise { + const memberIds = await getOrgMemberIds(organizationId) + + if (memberIds.length === 0) { + return 0 + } + + // Don't overwrite dispute blocks with payment_failed (dispute is higher priority) + const whereClause = + reason === 'payment_failed' + ? and( + inArray(userStats.userId, memberIds), + or(ne(userStats.billingBlockedReason, 'dispute'), isNull(userStats.billingBlockedReason)) + ) + : inArray(userStats.userId, memberIds) + + const result = await db + .update(userStats) + .set({ billingBlocked: true, billingBlockedReason: reason }) + .where(whereClause) + .returning({ userId: userStats.userId }) + + return result.length +} + +/** + * Unblock all members of an organization blocked for a specific reason + * Only unblocks members blocked for the specified reason (not other reasons) + * Returns the number of members actually unblocked + */ +export async function unblockOrgMembers( + organizationId: string, + reason: BillingBlockReason +): Promise { + const memberIds = await getOrgMemberIds(organizationId) + + if (memberIds.length === 0) { + return 0 + } + + const result = await db + .update(userStats) + .set({ billingBlocked: false, billingBlockedReason: null }) + .where(and(inArray(userStats.userId, memberIds), eq(userStats.billingBlockedReason, reason))) + .returning({ userId: userStats.userId }) + + return result.length +} + export interface RestoreProResult { restored: boolean usageRestored: boolean diff --git a/apps/sim/lib/billing/webhooks/disputes.ts b/apps/sim/lib/billing/webhooks/disputes.ts index e8b82e28b..647ad8a9c 100644 --- a/apps/sim/lib/billing/webhooks/disputes.ts +++ b/apps/sim/lib/billing/webhooks/disputes.ts @@ -1,8 +1,9 @@ import { db } from '@sim/db' -import { member, subscription, user, userStats } from '@sim/db/schema' +import { subscription, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import type Stripe from 'stripe' +import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing' import { requireStripeClient } from '@/lib/billing/stripe-client' const logger = createLogger('DisputeWebhooks') @@ -57,36 +58,34 @@ export async function handleChargeDispute(event: Stripe.Event): Promise { if (subs.length > 0) { const orgId = subs[0].referenceId + const memberCount = await blockOrgMembers(orgId, 'dispute') - const owners = await db - .select({ userId: member.userId }) - .from(member) - .where(and(eq(member.organizationId, orgId), eq(member.role, 'owner'))) - .limit(1) - - if (owners.length > 0) { - await db - .update(userStats) - .set({ billingBlocked: true, billingBlockedReason: 'dispute' }) - .where(eq(userStats.userId, owners[0].userId)) - - logger.warn('Blocked org owner due to dispute', { + if (memberCount > 0) { + logger.warn('Blocked all org members due to dispute', { disputeId: dispute.id, - ownerId: owners[0].userId, organizationId: orgId, + memberCount, }) } } } /** - * Handles charge.dispute.closed - unblocks user if dispute was won + * Handles charge.dispute.closed - unblocks user if dispute was won or warning closed + * + * Status meanings: + * - 'won': Merchant won, customer's chargeback denied → unblock + * - 'lost': Customer won, money refunded → stay blocked (they owe us) + * - 'warning_closed': Pre-dispute inquiry closed without chargeback → unblock (false alarm) */ export async function handleDisputeClosed(event: Stripe.Event): Promise { const dispute = event.data.object as Stripe.Dispute - if (dispute.status !== 'won') { - logger.info('Dispute not won, user remains blocked', { + // Only unblock if we won or the warning was closed without a full dispute + const shouldUnblock = dispute.status === 'won' || dispute.status === 'warning_closed' + + if (!shouldUnblock) { + logger.info('Dispute resolved against us, user remains blocked', { disputeId: dispute.id, status: dispute.status, }) @@ -98,7 +97,7 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise { return } - // Find and unblock user (Pro plans) + // Find and unblock user (Pro plans) - only if blocked for dispute, not other reasons const users = await db .select({ id: user.id }) .from(user) @@ -109,16 +108,17 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise { await db .update(userStats) .set({ billingBlocked: false, billingBlockedReason: null }) - .where(eq(userStats.userId, users[0].id)) + .where(and(eq(userStats.userId, users[0].id), eq(userStats.billingBlockedReason, 'dispute'))) - logger.info('Unblocked user after winning dispute', { + logger.info('Unblocked user after dispute resolved in our favor', { disputeId: dispute.id, userId: users[0].id, + status: dispute.status, }) return } - // Find and unblock org owner (Team/Enterprise) + // Find and unblock all org members (Team/Enterprise) - consistent with payment success const subs = await db .select({ referenceId: subscription.referenceId }) .from(subscription) @@ -127,24 +127,13 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise { if (subs.length > 0) { const orgId = subs[0].referenceId + const memberCount = await unblockOrgMembers(orgId, 'dispute') - const owners = await db - .select({ userId: member.userId }) - .from(member) - .where(and(eq(member.organizationId, orgId), eq(member.role, 'owner'))) - .limit(1) - - if (owners.length > 0) { - await db - .update(userStats) - .set({ billingBlocked: false, billingBlockedReason: null }) - .where(eq(userStats.userId, owners[0].userId)) - - logger.info('Unblocked org owner after winning dispute', { - disputeId: dispute.id, - ownerId: owners[0].userId, - organizationId: orgId, - }) - } + logger.info('Unblocked all org members after dispute resolved in our favor', { + disputeId: dispute.id, + organizationId: orgId, + memberCount, + status: dispute.status, + }) } } diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts index e06bbb8fe..2c1419631 100644 --- a/apps/sim/lib/billing/webhooks/invoices.ts +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -8,12 +8,13 @@ import { userStats, } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray } from 'drizzle-orm' +import { and, eq, inArray, isNull, ne, or } from 'drizzle-orm' import type Stripe from 'stripe' import { getEmailSubject, PaymentFailedEmail, renderCreditPurchaseEmail } from '@/components/emails' import { calculateSubscriptionOverage } from '@/lib/billing/core/billing' import { addCredits, getCreditBalance, removeCredits } from '@/lib/billing/credits/balance' import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase' +import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing/organizations/membership' import { requireStripeClient } from '@/lib/billing/stripe-client' import { getBaseUrl } from '@/lib/core/utils/urls' import { sendEmail } from '@/lib/messaging/email/mailer' @@ -502,24 +503,7 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) { } if (sub.plan === 'team' || sub.plan === 'enterprise') { - const members = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, sub.referenceId)) - const memberIds = members.map((m) => m.userId) - - if (memberIds.length > 0) { - // Only unblock users blocked for payment_failed, not disputes - await db - .update(userStats) - .set({ billingBlocked: false, billingBlockedReason: null }) - .where( - and( - inArray(userStats.userId, memberIds), - eq(userStats.billingBlockedReason, 'payment_failed') - ) - ) - } + await unblockOrgMembers(sub.referenceId, 'payment_failed') } else { // Only unblock users blocked for payment_failed, not disputes await db @@ -616,28 +600,26 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) { if (records.length > 0) { const sub = records[0] if (sub.plan === 'team' || sub.plan === 'enterprise') { - const members = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, sub.referenceId)) - const memberIds = members.map((m) => m.userId) - - if (memberIds.length > 0) { - await db - .update(userStats) - .set({ billingBlocked: true, billingBlockedReason: 'payment_failed' }) - .where(inArray(userStats.userId, memberIds)) - } + const memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed') logger.info('Blocked team/enterprise members due to payment failure', { organizationId: sub.referenceId, - memberCount: members.length, + memberCount, isOverageInvoice, }) } else { + // Don't overwrite dispute blocks (dispute > payment_failed priority) await db .update(userStats) .set({ billingBlocked: true, billingBlockedReason: 'payment_failed' }) - .where(eq(userStats.userId, sub.referenceId)) + .where( + and( + eq(userStats.userId, sub.referenceId), + or( + ne(userStats.billingBlockedReason, 'dispute'), + isNull(userStats.billingBlockedReason) + ) + ) + ) logger.info('Blocked user due to payment failure', { userId: sub.referenceId, isOverageInvoice, diff --git a/apps/sim/lib/billing/webhooks/subscription.ts b/apps/sim/lib/billing/webhooks/subscription.ts index 5553bd573..bf139cfe3 100644 --- a/apps/sim/lib/billing/webhooks/subscription.ts +++ b/apps/sim/lib/billing/webhooks/subscription.ts @@ -3,6 +3,7 @@ import { member, organization, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, ne } from 'drizzle-orm' import { calculateSubscriptionOverage } from '@/lib/billing/core/billing' +import { hasActiveSubscription } from '@/lib/billing/core/subscription' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' import { restoreUserProSubscription } from '@/lib/billing/organizations/membership' import { requireStripeClient } from '@/lib/billing/stripe-client' @@ -52,14 +53,37 @@ async function restoreMemberProSubscriptions(organizationId: string): Promise { + // Check if other active subscriptions still point to this org + // Note: The subscription being deleted is already marked as 'canceled' by better-auth + // before this handler runs, so we only find truly active ones + if (await hasActiveSubscription(organizationId)) { + logger.info('Skipping organization deletion - other active subscriptions exist', { + organizationId, + }) + + // Still sync limits for members since this subscription was deleted + const memberUserIds = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, organizationId)) + + for (const m of memberUserIds) { + await syncUsageLimitsFromSubscription(m.userId) + } + + return { restoredProCount: 0, membersSynced: memberUserIds.length, organizationDeleted: false } + } + // Get member userIds before deletion (needed for limit syncing after org deletion) const memberUserIds = await db .select({ userId: member.userId }) @@ -75,7 +99,7 @@ async function cleanupOrganizationSubscription(organizationId: string): Promise< await syncUsageLimitsFromSubscription(m.userId) } - return { restoredProCount, membersSynced: memberUserIds.length } + return { restoredProCount, membersSynced: memberUserIds.length, organizationDeleted: true } } /** @@ -172,15 +196,14 @@ export async function handleSubscriptionDeleted(subscription: { referenceId: subscription.referenceId, }) - const { restoredProCount, membersSynced } = await cleanupOrganizationSubscription( - subscription.referenceId - ) + const { restoredProCount, membersSynced, organizationDeleted } = + await cleanupOrganizationSubscription(subscription.referenceId) logger.info('Successfully processed enterprise subscription cancellation', { subscriptionId: subscription.id, stripeSubscriptionId, restoredProCount, - organizationDeleted: true, + organizationDeleted, membersSynced, }) return @@ -297,7 +320,7 @@ export async function handleSubscriptionDeleted(subscription: { const cleanup = await cleanupOrganizationSubscription(subscription.referenceId) restoredProCount = cleanup.restoredProCount membersSynced = cleanup.membersSynced - organizationDeleted = true + organizationDeleted = cleanup.organizationDeleted } else if (subscription.plan === 'pro') { await syncUsageLimitsFromSubscription(subscription.referenceId) membersSynced = 1 diff --git a/apps/sim/lib/copilot/process-contents.ts b/apps/sim/lib/copilot/process-contents.ts index ff1dbf497..13a0015f0 100644 --- a/apps/sim/lib/copilot/process-contents.ts +++ b/apps/sim/lib/copilot/process-contents.ts @@ -5,8 +5,8 @@ import { and, eq, isNull } from 'drizzle-orm' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { isHiddenFromDisplay } from '@/blocks/types' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { escapeRegExp } from '@/executor/constants' -import { getUserPermissionConfig } from '@/executor/utils/permission-check' import type { ChatContext } from '@/stores/panel/copilot/types' export type AgentContextType = diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-block-config.ts b/apps/sim/lib/copilot/tools/server/blocks/get-block-config.ts index 3d6ebba17..cd95577d7 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-block-config.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-block-config.ts @@ -7,7 +7,7 @@ import { } from '@/lib/copilot/tools/shared/schemas' import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry' import { isHiddenFromDisplay, type SubBlockConfig } from '@/blocks/types' -import { getUserPermissionConfig } from '@/executor/utils/permission-check' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { PROVIDER_DEFINITIONS } from '@/providers/models' import { tools as toolsRegistry } from '@/tools/registry' import { getTrigger, isTriggerValid } from '@/triggers' diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-block-options.ts b/apps/sim/lib/copilot/tools/server/blocks/get-block-options.ts index b5e5b2373..177482fc3 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-block-options.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-block-options.ts @@ -6,7 +6,7 @@ import { type GetBlockOptionsResultType, } from '@/lib/copilot/tools/shared/schemas' import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry' -import { getUserPermissionConfig } from '@/executor/utils/permission-check' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { tools as toolsRegistry } from '@/tools/registry' export const getBlockOptionsServerTool: BaseServerTool< diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts index 222288aab..9413dc278 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts @@ -6,7 +6,7 @@ import { } from '@/lib/copilot/tools/shared/schemas' import { registry as blockRegistry } from '@/blocks/registry' import type { BlockConfig } from '@/blocks/types' -import { getUserPermissionConfig } from '@/executor/utils/permission-check' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' export const getBlocksAndToolsServerTool: BaseServerTool< ReturnType, diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts index dc4615777..7b945d6b0 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts @@ -8,7 +8,7 @@ import { } from '@/lib/copilot/tools/shared/schemas' import { registry as blockRegistry } from '@/blocks/registry' import { AuthMode, type BlockConfig, isHiddenFromDisplay } from '@/blocks/types' -import { getUserPermissionConfig } from '@/executor/utils/permission-check' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { PROVIDER_DEFINITIONS } from '@/providers/models' import { tools as toolsRegistry } from '@/tools/registry' import { getTrigger, isTriggerValid } from '@/triggers' diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts index c5f3b75b4..5f5820e20 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts @@ -3,7 +3,7 @@ import { z } from 'zod' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { registry as blockRegistry } from '@/blocks/registry' import type { BlockConfig } from '@/blocks/types' -import { getUserPermissionConfig } from '@/executor/utils/permission-check' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' export const GetTriggerBlocksInput = z.object({}) export const GetTriggerBlocksResult = z.object({ diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts index 51c2669c2..66bb54ffa 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts @@ -15,8 +15,8 @@ import { buildCanonicalIndex, isCanonicalPair } from '@/lib/workflows/subblocks/ import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { getAllBlocks, getBlock } from '@/blocks/registry' import type { BlockConfig, SubBlockConfig } from '@/blocks/types' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { EDGE, normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants' -import { getUserPermissionConfig } from '@/executor/utils/permission-check' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants' diff --git a/apps/sim/lib/core/utils/formatting.ts b/apps/sim/lib/core/utils/formatting.ts index abd0f8805..a7051df03 100644 --- a/apps/sim/lib/core/utils/formatting.ts +++ b/apps/sim/lib/core/utils/formatting.ts @@ -153,22 +153,50 @@ export function formatCompactTimestamp(iso: string): string { } /** - * Format a duration in milliseconds to a human-readable format - * @param durationMs - The duration in milliseconds + * Format a duration to a human-readable format + * @param duration - Duration in milliseconds (number) or as string (e.g., "500ms") * @param options - Optional formatting options - * @param options.precision - Number of decimal places for seconds (default: 0) - * @returns A formatted duration string + * @param options.precision - Number of decimal places for seconds (default: 0), trailing zeros are stripped + * @returns A formatted duration string, or null if input is null/undefined */ -export function formatDuration(durationMs: number, options?: { precision?: number }): string { - const precision = options?.precision ?? 0 - - if (durationMs < 1000) { - return `${durationMs}ms` +export function formatDuration( + duration: number | string | undefined | null, + options?: { precision?: number } +): string | null { + if (duration === undefined || duration === null) { + return null } - const seconds = durationMs / 1000 + // Parse string durations (e.g., "500ms", "0.44ms", "1234") + let ms: number + if (typeof duration === 'string') { + ms = Number.parseFloat(duration.replace(/[^0-9.-]/g, '')) + if (!Number.isFinite(ms)) { + return duration + } + } else { + ms = duration + } + + const precision = options?.precision ?? 0 + + if (ms < 1) { + // Sub-millisecond: show with 2 decimal places + return `${ms.toFixed(2)}ms` + } + + if (ms < 1000) { + // Milliseconds: round to integer + return `${Math.round(ms)}ms` + } + + const seconds = ms / 1000 if (seconds < 60) { - return precision > 0 ? `${seconds.toFixed(precision)}s` : `${Math.floor(seconds)}s` + if (precision > 0) { + // Strip trailing zeros (e.g., "5.00s" -> "5s", "5.10s" -> "5.1s") + return `${seconds.toFixed(precision).replace(/\.?0+$/, '')}s` + } + return `${Math.floor(seconds)}s` } const minutes = Math.floor(seconds / 60) diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 68cd9c22d..055b8fe26 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -33,6 +33,7 @@ import type { WorkflowExecutionSnapshot, WorkflowState, } from '@/lib/logs/types' +import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' export interface ToolCall { name: string @@ -503,7 +504,7 @@ export class ExecutionLogger implements IExecutionLoggerService { } try { - // Get the workflow record to get the userId + // Get the workflow record to get workspace and fallback userId const [workflowRecord] = await db .select() .from(workflow) @@ -515,7 +516,12 @@ export class ExecutionLogger implements IExecutionLoggerService { return } - const userId = workflowRecord.userId + let billingUserId: string | null = null + if (workflowRecord.workspaceId) { + billingUserId = await getWorkspaceBilledAccountUserId(workflowRecord.workspaceId) + } + + const userId = billingUserId || workflowRecord.userId const costToStore = costSummary.totalCost const existing = await db.select().from(userStats).where(eq(userStats.userId, userId)) diff --git a/apps/sim/tools/enrich/check_credits.ts b/apps/sim/tools/enrich/check_credits.ts new file mode 100644 index 000000000..34b8a2063 --- /dev/null +++ b/apps/sim/tools/enrich/check_credits.ts @@ -0,0 +1,55 @@ +import type { EnrichCheckCreditsParams, EnrichCheckCreditsResponse } from '@/tools/enrich/types' +import type { ToolConfig } from '@/tools/types' + +export const checkCreditsTool: ToolConfig = { + 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', + }, + }, +} diff --git a/apps/sim/tools/enrich/company_funding.ts b/apps/sim/tools/enrich/company_funding.ts new file mode 100644 index 000000000..53a638850 --- /dev/null +++ b/apps/sim/tools/enrich/company_funding.ts @@ -0,0 +1,143 @@ +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' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/enrich/company_lookup.ts b/apps/sim/tools/enrich/company_lookup.ts new file mode 100644 index 000000000..9f0036578 --- /dev/null +++ b/apps/sim/tools/enrich/company_lookup.ts @@ -0,0 +1,197 @@ +import type { EnrichCompanyLookupParams, EnrichCompanyLookupResponse } from '@/tools/enrich/types' +import type { ToolConfig } from '@/tools/types' + +export const companyLookupTool: ToolConfig = + { + 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' }, + }, + }, + }, + }, + } diff --git a/apps/sim/tools/enrich/company_revenue.ts b/apps/sim/tools/enrich/company_revenue.ts new file mode 100644 index 000000000..a4ba41f1b --- /dev/null +++ b/apps/sim/tools/enrich/company_revenue.ts @@ -0,0 +1,215 @@ +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' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/enrich/disposable_email_check.ts b/apps/sim/tools/enrich/disposable_email_check.ts new file mode 100644 index 000000000..ecab02515 --- /dev/null +++ b/apps/sim/tools/enrich/disposable_email_check.ts @@ -0,0 +1,102 @@ +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' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/enrich/email_to_ip.ts b/apps/sim/tools/enrich/email_to_ip.ts new file mode 100644 index 000000000..01073bb1e --- /dev/null +++ b/apps/sim/tools/enrich/email_to_ip.ts @@ -0,0 +1,67 @@ +import type { EnrichEmailToIpParams, EnrichEmailToIpResponse } from '@/tools/enrich/types' +import type { ToolConfig } from '@/tools/types' + +export const emailToIpTool: ToolConfig = { + 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', + }, + }, +} diff --git a/apps/sim/tools/enrich/email_to_person_lite.ts b/apps/sim/tools/enrich/email_to_person_lite.ts new file mode 100644 index 000000000..b707c056d --- /dev/null +++ b/apps/sim/tools/enrich/email_to_person_lite.ts @@ -0,0 +1,177 @@ +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' }, + }, + }, +} diff --git a/apps/sim/tools/enrich/email_to_phone.ts b/apps/sim/tools/enrich/email_to_phone.ts new file mode 100644 index 000000000..6a390c828 --- /dev/null +++ b/apps/sim/tools/enrich/email_to_phone.ts @@ -0,0 +1,86 @@ +import type { EnrichEmailToPhoneParams, EnrichEmailToPhoneResponse } from '@/tools/enrich/types' +import type { ToolConfig } from '@/tools/types' + +export const emailToPhoneTool: ToolConfig = { + 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, + }, + }, +} diff --git a/apps/sim/tools/enrich/email_to_profile.ts b/apps/sim/tools/enrich/email_to_profile.ts new file mode 100644 index 000000000..64a792025 --- /dev/null +++ b/apps/sim/tools/enrich/email_to_profile.ts @@ -0,0 +1,239 @@ +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, + }, + }, +} diff --git a/apps/sim/tools/enrich/find_email.ts b/apps/sim/tools/enrich/find_email.ts new file mode 100644 index 000000000..134b25caf --- /dev/null +++ b/apps/sim/tools/enrich/find_email.ts @@ -0,0 +1,107 @@ +import type { EnrichFindEmailParams, EnrichFindEmailResponse } from '@/tools/enrich/types' +import type { ToolConfig } from '@/tools/types' + +export const findEmailTool: ToolConfig = { + 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, + }, + }, +} diff --git a/apps/sim/tools/enrich/get_post_details.ts b/apps/sim/tools/enrich/get_post_details.ts new file mode 100644 index 000000000..270866599 --- /dev/null +++ b/apps/sim/tools/enrich/get_post_details.ts @@ -0,0 +1,116 @@ +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', + }, + }, +} diff --git a/apps/sim/tools/enrich/index.ts b/apps/sim/tools/enrich/index.ts new file mode 100644 index 000000000..935024f47 --- /dev/null +++ b/apps/sim/tools/enrich/index.ts @@ -0,0 +1,59 @@ +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 diff --git a/apps/sim/tools/enrich/ip_to_company.ts b/apps/sim/tools/enrich/ip_to_company.ts new file mode 100644 index 000000000..eaf803941 --- /dev/null +++ b/apps/sim/tools/enrich/ip_to_company.ts @@ -0,0 +1,148 @@ +import type { EnrichIpToCompanyParams, EnrichIpToCompanyResponse } from '@/tools/enrich/types' +import type { ToolConfig } from '@/tools/types' + +export const ipToCompanyTool: ToolConfig = { + 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, + }, + }, +} diff --git a/apps/sim/tools/enrich/linkedin_profile.ts b/apps/sim/tools/enrich/linkedin_profile.ts new file mode 100644 index 000000000..ef1e4ad0c --- /dev/null +++ b/apps/sim/tools/enrich/linkedin_profile.ts @@ -0,0 +1,190 @@ +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', + }, + }, + }, +} diff --git a/apps/sim/tools/enrich/linkedin_to_personal_email.ts b/apps/sim/tools/enrich/linkedin_to_personal_email.ts new file mode 100644 index 000000000..2e7262126 --- /dev/null +++ b/apps/sim/tools/enrich/linkedin_to_personal_email.ts @@ -0,0 +1,75 @@ +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, + }, + }, +} diff --git a/apps/sim/tools/enrich/linkedin_to_work_email.ts b/apps/sim/tools/enrich/linkedin_to_work_email.ts new file mode 100644 index 000000000..2f0b91c0d --- /dev/null +++ b/apps/sim/tools/enrich/linkedin_to_work_email.ts @@ -0,0 +1,85 @@ +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, + }, + }, +} diff --git a/apps/sim/tools/enrich/phone_finder.ts b/apps/sim/tools/enrich/phone_finder.ts new file mode 100644 index 000000000..2aed6040f --- /dev/null +++ b/apps/sim/tools/enrich/phone_finder.ts @@ -0,0 +1,86 @@ +import type { EnrichPhoneFinderParams, EnrichPhoneFinderResponse } from '@/tools/enrich/types' +import type { ToolConfig } from '@/tools/types' + +export const phoneFinderTool: ToolConfig = { + 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, + }, + }, +} diff --git a/apps/sim/tools/enrich/reverse_hash_lookup.ts b/apps/sim/tools/enrich/reverse_hash_lookup.ts new file mode 100644 index 000000000..49d29519e --- /dev/null +++ b/apps/sim/tools/enrich/reverse_hash_lookup.ts @@ -0,0 +1,79 @@ +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', + }, + }, +} diff --git a/apps/sim/tools/enrich/sales_pointer_people.ts b/apps/sim/tools/enrich/sales_pointer_people.ts new file mode 100644 index 000000000..2edd7be8c --- /dev/null +++ b/apps/sim/tools/enrich/sales_pointer_people.ts @@ -0,0 +1,133 @@ +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' }, + }, + }, + }, +} diff --git a/apps/sim/tools/enrich/search_company.ts b/apps/sim/tools/enrich/search_company.ts new file mode 100644 index 000000000..ec7a90b52 --- /dev/null +++ b/apps/sim/tools/enrich/search_company.ts @@ -0,0 +1,216 @@ +import type { EnrichSearchCompanyParams, EnrichSearchCompanyResponse } from '@/tools/enrich/types' +import type { ToolConfig } from '@/tools/types' + +export const searchCompanyTool: ToolConfig = + { + 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 = {} + + 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' }, + }, + }, + }, + }, + } diff --git a/apps/sim/tools/enrich/search_company_activities.ts b/apps/sim/tools/enrich/search_company_activities.ts new file mode 100644 index 000000000..7628ae38b --- /dev/null +++ b/apps/sim/tools/enrich/search_company_activities.ts @@ -0,0 +1,157 @@ +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' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/enrich/search_company_employees.ts b/apps/sim/tools/enrich/search_company_employees.ts new file mode 100644 index 000000000..cf053a025 --- /dev/null +++ b/apps/sim/tools/enrich/search_company_employees.ts @@ -0,0 +1,149 @@ +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 = {} + + 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' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/enrich/search_logo.ts b/apps/sim/tools/enrich/search_logo.ts new file mode 100644 index 000000000..184cef6c3 --- /dev/null +++ b/apps/sim/tools/enrich/search_logo.ts @@ -0,0 +1,77 @@ +import type { EnrichSearchLogoParams, EnrichSearchLogoResponse } from '@/tools/enrich/types' +import type { ToolConfig } from '@/tools/types' + +export const searchLogoTool: ToolConfig = { + 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', + }, + }, +} diff --git a/apps/sim/tools/enrich/search_people.ts b/apps/sim/tools/enrich/search_people.ts new file mode 100644 index 000000000..85f35f223 --- /dev/null +++ b/apps/sim/tools/enrich/search_people.ts @@ -0,0 +1,249 @@ +import type { EnrichSearchPeopleParams, EnrichSearchPeopleResponse } from '@/tools/enrich/types' +import type { ToolConfig } from '@/tools/types' + +export const searchPeopleTool: ToolConfig = { + 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 = {} + + 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' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/enrich/search_people_activities.ts b/apps/sim/tools/enrich/search_people_activities.ts new file mode 100644 index 000000000..a4577ae33 --- /dev/null +++ b/apps/sim/tools/enrich/search_people_activities.ts @@ -0,0 +1,141 @@ +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' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/enrich/search_post_comments.ts b/apps/sim/tools/enrich/search_post_comments.ts new file mode 100644 index 000000000..0f8d209f0 --- /dev/null +++ b/apps/sim/tools/enrich/search_post_comments.ts @@ -0,0 +1,143 @@ +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' }, + }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/enrich/search_post_reactions.ts b/apps/sim/tools/enrich/search_post_reactions.ts new file mode 100644 index 000000000..6290ac132 --- /dev/null +++ b/apps/sim/tools/enrich/search_post_reactions.ts @@ -0,0 +1,121 @@ +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' }, + }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/enrich/search_posts.ts b/apps/sim/tools/enrich/search_posts.ts new file mode 100644 index 000000000..d26a60e9d --- /dev/null +++ b/apps/sim/tools/enrich/search_posts.ts @@ -0,0 +1,120 @@ +import type { EnrichSearchPostsParams, EnrichSearchPostsResponse } from '@/tools/enrich/types' +import type { ToolConfig } from '@/tools/types' + +export const searchPostsTool: ToolConfig = { + 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 = { + 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' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/enrich/search_similar_companies.ts b/apps/sim/tools/enrich/search_similar_companies.ts new file mode 100644 index 000000000..a31eeaf23 --- /dev/null +++ b/apps/sim/tools/enrich/search_similar_companies.ts @@ -0,0 +1,146 @@ +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 = { + 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' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/enrich/types.ts b/apps/sim/tools/enrich/types.ts new file mode 100644 index 000000000..f635ae0f8 --- /dev/null +++ b/apps/sim/tools/enrich/types.ts @@ -0,0 +1,742 @@ +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 + } +} diff --git a/apps/sim/tools/enrich/verify_email.ts b/apps/sim/tools/enrich/verify_email.ts new file mode 100644 index 000000000..ae180fe63 --- /dev/null +++ b/apps/sim/tools/enrich/verify_email.ts @@ -0,0 +1,92 @@ +import type { EnrichVerifyEmailParams, EnrichVerifyEmailResponse } from '@/tools/enrich/types' +import type { ToolConfig } from '@/tools/types' + +export const verifyEmailTool: ToolConfig = { + 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', + }, + }, +} diff --git a/apps/sim/tools/http/request.test.ts b/apps/sim/tools/http/request.test.ts index d338a030c..88c2e9086 100644 --- a/apps/sim/tools/http/request.test.ts +++ b/apps/sim/tools/http/request.test.ts @@ -286,6 +286,30 @@ describe('HTTP Request Tool', () => { ) }) + it('should handle nested objects and arrays in URL-encoded form data', async () => { + tester.setup({ result: 'success' }) + + const body = { + name: 'test', + data: { nested: 'value' }, + items: [1, 2, 3], + } + + await tester.execute({ + url: 'https://api.example.com/submit', + method: 'POST', + body, + headers: [{ cells: { Key: 'Content-Type', Value: 'application/x-www-form-urlencoded' } }], + }) + + const fetchCall = (global.fetch as any).mock.calls[0] + const bodyStr = fetchCall[1].body + + expect(bodyStr).toContain('name=test') + expect(bodyStr).toContain('data=%7B%22nested%22%3A%22value%22%7D') + expect(bodyStr).toContain('items=%5B1%2C2%2C3%5D') + }) + it('should handle OAuth client credentials requests', async () => { tester.setup({ access_token: 'token123', token_type: 'Bearer' }) diff --git a/apps/sim/tools/http/request.ts b/apps/sim/tools/http/request.ts index dbc74df4d..687ccb46f 100644 --- a/apps/sim/tools/http/request.ts +++ b/apps/sim/tools/http/request.ts @@ -105,7 +105,10 @@ export const requestTool: ToolConfig = { const urlencoded = new URLSearchParams() Object.entries(params.body as Record).forEach(([key, value]) => { if (value !== undefined && value !== null) { - urlencoded.append(key, String(value)) + urlencoded.append( + key, + typeof value === 'object' ? JSON.stringify(value) : String(value) + ) } }) return urlencoded.toString() diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 0d396d8c0..6018a6f86 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -232,6 +232,37 @@ import { elasticsearchUpdateDocumentTool, } from '@/tools/elasticsearch' 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 { exaAnswerTool, exaFindSimilarLinksTool, @@ -2395,6 +2426,35 @@ export const tools: Record = { elasticsearch_list_indices: elasticsearchListIndicesTool, elasticsearch_cluster_health: elasticsearchClusterHealthTool, 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_get_contents: exaGetContentsTool, exa_find_similar_links: exaFindSimilarLinksTool,