diff --git a/apps/sim/blocks/blocks/harmonic.ts b/apps/sim/blocks/blocks/harmonic.ts index a30f7bd0295..f20b4ceee54 100644 --- a/apps/sim/blocks/blocks/harmonic.ts +++ b/apps/sim/blocks/blocks/harmonic.ts @@ -358,7 +358,7 @@ export const HarmonicBlock: BlockConfig = { language: 'json', placeholder: '["urn:harmonic:person:22", "urn:harmonic:person:1690"]', description: - 'Batch Get requires at least one Person URN or Person ID. Clear Net-New Results clears everything when omitted', + 'Batch Get requires at least one Person URN or Person ID. Clear Net-New Results requires at least one URN unless Clear Scope is set to every net-new result', condition: { field: 'operation', value: [...PERSON_URN_OPERATIONS] }, paramVisibility: 'user-or-llm', wandConfig: { @@ -838,7 +838,7 @@ export const HarmonicBlockMeta = { description: 'Turn LinkedIn URLs or email addresses a workflow already holds into Harmonic contacts.', content: - '# Enrich Known Identifiers\n\nUse Enrich Person when the workflow already has an identifier rather than a description of who to find.\n\n## Steps\n1. Prefer the LinkedIn profile URL; supply the email only as a fallback identifier.\n2. Run Enrich Person once per identifier and keep personUrn from every match.\n3. When Harmonic reports the person is not on file, capture the enrichment it scheduled and poll Get Enrichment Status until it is COMPLETE or FAILED.\n4. Read the resulting person with Get Person or Batch Get People once enrichment completes.\n\n## Output\nReturn the hydrated contacts, the identifiers still pending enrichment, and the identifiers Harmonic could not resolve. Do not invent contact fields for unresolved rows.', + '# Enrich Known Identifiers\n\nUse Enrich Person when the workflow already has an identifier rather than a description of who to find.\n\n## Steps\n1. Prefer the LinkedIn profile URL; supply the email only as a fallback identifier.\n2. Run Enrich Person once per identifier and keep personUrn from every match.\n3. A person Harmonic does not have yet fails the block rather than returning a row: the error names the enrichment that was scheduled and carries its URN. Handle that error instead of treating it as a match, and poll Get Enrichment Status with the URN until it is COMPLETE or FAILED.\n4. Read the resulting person with Get Person or Batch Get People once enrichment completes.\n\n## Output\nReturn the hydrated contacts, the identifiers still pending enrichment, and the identifiers Harmonic could not resolve. Do not invent contact fields for unresolved rows.', }, { name: 'source-company-employees', diff --git a/apps/sim/tools/error-extractors.ts b/apps/sim/tools/error-extractors.ts index 67f0800108a..13d2a589518 100644 --- a/apps/sim/tools/error-extractors.ts +++ b/apps/sim/tools/error-extractors.ts @@ -213,7 +213,7 @@ const ERROR_EXTRACTORS: ErrorExtractorConfig[] = [ { id: 'harmonic-errors', description: - 'Harmonic API message errors, string and object FastAPI detail aborts including the enrichment URN, and validation detail arrays without echoed request input', + 'Harmonic API message errors, string and object FastAPI detail aborts including the enrichment URN, bulk email-enrichment error codes with their quota counters, and validation detail arrays without echoed request input', examples: ['Harmonic'], extract: (errorInfo) => { const data = errorInfo?.data @@ -241,12 +241,30 @@ const ERROR_EXTRACTORS: ErrorExtractorConfig[] = [ if (data.detail && typeof data.detail === 'object' && !Array.isArray(data.detail)) { const detail = data.detail as { message?: unknown; enrichment_urn?: unknown } const detailMessage = typeof detail.message === 'string' ? detail.message.trim() : '' - if (!detailMessage) return undefined const enrichmentUrn = typeof detail.enrichment_urn === 'string' ? detail.enrichment_urn.trim() : '' + if (!detailMessage) return enrichmentUrn || undefined return enrichmentUrn ? `${detailMessage} (${enrichmentUrn})` : detailMessage } + /** + * The bulk email-enrichment endpoint answers 422/429 with a code in `error` + * and no message anywhere — `{error: 'MONTHLY_QUOTA_INSUFFICIENT', needed, + * available, submitted}`. These are the most actionable failures on that path. + * + * Gated on one of the documented numeric counters being present. `error` alone + * is far too common a key to claim: `extractErrorMessage` without an explicit + * id walks every extractor in order, so a bare `error` check here would swallow + * OAuth's `{error, error_description}` and return the code instead of the text. + */ + const emailJobCounters = (['needed', 'available', 'submitted'] as const).filter( + (key) => typeof data[key] === 'number' + ) + if (typeof data.error === 'string' && data.error.trim() && emailJobCounters.length > 0) { + const code = data.error.trim() + return `${code} (${emailJobCounters.map((key) => `${key} ${data[key]}`).join(', ')})` + } + if (!Array.isArray(data.detail)) return undefined const details = data.detail .map((entry: unknown) => { diff --git a/apps/sim/tools/harmonic/harmonic.test.ts b/apps/sim/tools/harmonic/harmonic.test.ts index f7cdcad77c0..66d21f1ae56 100644 --- a/apps/sim/tools/harmonic/harmonic.test.ts +++ b/apps/sim/tools/harmonic/harmonic.test.ts @@ -230,6 +230,7 @@ describe('Harmonic authentication and registry-facing contracts', () => { expect(headers.Authorization).toBeUndefined() } + /** One sample per registered tool: every URL builder interpolates user input. */ const requestSamples: Array<[ToolConfig, Record]> = [ [harmonicSearchPeopleScoutTool, { accessToken: 'team-secret', query: 'find FDEs' }], [harmonicListPeopleSavedSearchesTool, { accessToken: 'team-secret' }], @@ -238,7 +239,35 @@ describe('Harmonic authentication and registry-facing contracts', () => { { accessToken: 'team-secret', savedSearchId: 'urn:harmonic:saved_search:1' }, ], [harmonicBatchGetPeopleTool, { accessToken: 'team-secret', personIds: [1] }], + [ + harmonicEnrichPersonTool, + { accessToken: 'team-secret', linkedinUrl: 'https://www.linkedin.com/in/ada' }, + ], + [harmonicGetPersonTool, { accessToken: 'team-secret', personId: '123' }], + [harmonicGetCompanyEmployeesTool, { accessToken: 'team-secret', companyId: '1' }], + [ + harmonicGetPeopleSavedSearchNetNewResultsTool, + { accessToken: 'team-secret', savedSearchId: '5' }, + ], + [ + harmonicClearPeopleSavedSearchNetNewResultsTool, + { accessToken: 'team-secret', savedSearchId: '5', clearScope: 'all' }, + ], + [ + harmonicSubmitEmailEnrichmentJobTool, + { accessToken: 'team-secret', personUrns: ['urn:harmonic:person:1'] }, + ], + [harmonicGetEmailEnrichmentJobTool, { accessToken: 'team-secret', jobId: 'job-1' }], + [harmonicGetEmailEnrichmentUsageTool, { accessToken: 'team-secret' }], + [ + harmonicGetEnrichmentStatusTool, + { accessToken: 'team-secret', enrichmentUrns: ['urn:harmonic:enrichment:1'] }, + ], ] + expect(requestSamples).toHaveLength(allTools.length) + expect(new Set(requestSamples.map(([tool]) => tool.id))).toEqual( + new Set(allTools.map((tool) => tool.id)) + ) for (const [tool, params] of requestSamples) { expect(buildUrl(tool, params)).not.toContain('team-secret') if (tool.request.body) @@ -336,6 +365,13 @@ describe('Harmonic authentication and registry-facing contracts', () => { { status: 404, data: { detail: { enrichment_urn: 'urn:harmonic:enrichment:abc' } } }, harmonicEnrichPersonTool.errorExtractor ) + ).toBe('urn:harmonic:enrichment:abc') + + expect( + extractErrorMessage( + { status: 404, data: { detail: {} } }, + harmonicEnrichPersonTool.errorExtractor + ) ).toBe('Request failed with status 404') }) @@ -564,7 +600,6 @@ describe('Harmonic people retrieval', () => { ['entity_urn', 'urn:harmonic:company:1'], ['name', ' '], ['creator', 'urn:harmonic:company:1'], - ['user_saved_search_type', 'UNKNOWN'], ['created_at', 'yesterday'], ['created_at', '2026-02-31T12:34:56Z'], ['created_at', '2026-01-01T00:00:60Z'], @@ -579,6 +614,14 @@ describe('Harmonic people retrieval', () => { ).rejects.toThrow(/saved search/) }) + it('passes an unrecognized user_saved_search_type through instead of failing the list', async () => { + const result = await harmonicListPeopleSavedSearchesTool.transformResponse!( + jsonResponse([{ ...validPeopleSavedSearch, user_saved_search_type: 'SOMETHING_NEW' }]) + ) + expect(result.output.savedSearches).toHaveLength(1) + expect(result.output.savedSearches[0].userSavedSearchType).toBe('SOMETHING_NEW') + }) + it.each([ 'id', 'entity_urn', @@ -929,6 +972,15 @@ describe('Harmonic person enrichment', () => { } }) + it('rejects company context URNs from another entity family', () => { + expect(() => + buildUrl(harmonicGetPersonTool, { + personId: '123', + companyContextUrns: ['urn:harmonic:person:1'], + }) + ).toThrow('"companyContextUrns" must contain only company URNs') + }) + it('repeats company context URNs as query parameters', () => { expect( buildUrl(harmonicGetPersonTool, { @@ -1098,6 +1150,116 @@ describe('Harmonic email enrichment', () => { ).toThrow('must contain absolute http(s) URLs') }) + it('folds every spelling of one profile into a single submitted entry', () => { + expect( + buildBody(harmonicSubmitEmailEnrichmentJobTool, { + personLinkedinUrls: [ + 'https://www.linkedin.com/in/ada?utm_source=x', + 'https://www.linkedin.com/in/ada', + 'https://www.linkedin.com/in/ada#about', + 'https://www.linkedin.com/in/ada/', + 'https://linkedin.com/in/ada', + 'https://WWW.LinkedIn.com/in/ada', + ], + }) + ).toEqual({ person_linkedin_urls: ['https://www.linkedin.com/in/ada'] }) + }) + + it('keeps pass-through URLs distinct on every component Harmonic still sees', () => { + expect( + buildBody(harmonicSubmitEmailEnrichmentJobTool, { + personLinkedinUrls: [ + 'https://profiles.example/p?id=1', + 'https://profiles.example/p?id=2', + 'https://profiles.example:8443/p', + 'https://profiles.example/p#a', + 'https://profiles.example/p#b', + ], + }) + ).toEqual({ + person_linkedin_urls: [ + 'https://profiles.example/p?id=1', + 'https://profiles.example/p?id=2', + 'https://profiles.example:8443/p', + 'https://profiles.example/p#a', + 'https://profiles.example/p#b', + ], + }) + + expect( + buildBody(harmonicSubmitEmailEnrichmentJobTool, { + personLinkedinUrls: ['https://profiles.example/p?id=1', 'https://profiles.example/p?id=1'], + }) + ).toEqual({ person_linkedin_urls: ['https://profiles.example/p?id=1'] }) + }) + + it('keeps regional subdomains distinct rather than assuming an undocumented equivalence', () => { + expect( + buildBody(harmonicSubmitEmailEnrichmentJobTool, { + personLinkedinUrls: ['https://uk.linkedin.com/in/ada', 'https://www.linkedin.com/in/ada'], + }) + ).toEqual({ + person_linkedin_urls: ['https://uk.linkedin.com/in/ada', 'https://www.linkedin.com/in/ada'], + }) + }) + + it('treats blank LinkedIn entries as absent instead of a conflicting identifier list', () => { + expect( + buildBody(harmonicSubmitEmailEnrichmentJobTool, { + personUrns: ['urn:harmonic:person:1'], + personLinkedinUrls: ['', ' ', null], + }) + ).toEqual({ person_urns: ['urn:harmonic:person:1'] }) + + expect(() => + buildBody(harmonicSubmitEmailEnrichmentJobTool, { personLinkedinUrls: ['', ' '] }) + ).toThrow('requires at least one person URN or LinkedIn profile URL') + }) + + it('reports the identifier conflict before complaining about any single URL', () => { + expect(() => + buildBody(harmonicSubmitEmailEnrichmentJobTool, { + personUrns: ['urn:harmonic:person:1'], + personLinkedinUrls: ['not-a-url'], + }) + ).toThrow('accepts person URNs or LinkedIn URLs, not both') + }) + + it('surfaces the bulk email error codes with their quota counters', () => { + expect( + extractErrorMessage( + { + status: 429, + data: { + error: 'MONTHLY_QUOTA_INSUFFICIENT', + needed: 500, + available: 20, + submitted: 500, + }, + }, + harmonicSubmitEmailEnrichmentJobTool.errorExtractor + ) + ).toBe('MONTHLY_QUOTA_INSUFFICIENT (needed 500, available 20, submitted 500)') + + expect( + extractErrorMessage( + { status: 422, data: { error: 'NO_ELIGIBLE_PEOPLE', submitted: 3, dropped: [] } }, + harmonicSubmitEmailEnrichmentJobTool.errorExtractor + ) + ).toBe('NO_ELIGIBLE_PEOPLE (submitted 3)') + + /** + * `extractErrorMessage` with no id walks every extractor in order, so a bare + * `error` key here would hijack other providers' envelopes. + */ + expect( + extractErrorMessage({ + status: 400, + data: { error: 'invalid_grant', error_description: 'The grant is invalid' }, + }) + ).toBe('The grant is invalid') + }) + it('forwards unrecognised profile URLs so Harmonic can drop them per item', () => { expect( buildBody(harmonicSubmitEmailEnrichmentJobTool, { diff --git a/apps/sim/tools/harmonic/types.ts b/apps/sim/tools/harmonic/types.ts index 3d4ff52c8b6..0a4a82b27f7 100644 --- a/apps/sim/tools/harmonic/types.ts +++ b/apps/sim/tools/harmonic/types.ts @@ -120,24 +120,6 @@ export interface HarmonicEnrichmentOutput { enriched_entity_urn?: unknown } -export interface HarmonicDroppedPerson { - submitted_identifier?: unknown - reason?: unknown -} - -export interface HarmonicPersonJobResultOutput { - person_urn?: unknown - status?: unknown -} - -export interface HarmonicPersonJobCountsOutput { - total_processed?: unknown - total_succeeded?: unknown - total_failed?: unknown - total_skipped?: unknown - total_not_found?: unknown -} - export interface HarmonicEnrichmentStatus { enrichmentUrn: string | null status: string | null diff --git a/apps/sim/tools/harmonic/utils.ts b/apps/sim/tools/harmonic/utils.ts index 4dbe31abc39..dc3b6fe0b36 100644 --- a/apps/sim/tools/harmonic/utils.ts +++ b/apps/sim/tools/harmonic/utils.ts @@ -34,11 +34,8 @@ export const HARMONIC_EMPLOYEE_GROUP_TYPES = [ 'NON_PARTNERS', ] as const export const HARMONIC_EMPLOYEE_STATUSES = ['ACTIVE', 'NOT_ACTIVE', 'ACTIVE_AND_NOT_ACTIVE'] as const -export const HARMONIC_USER_CONNECTION_STATUSES = [ - 'USER_CONNECTION', - 'TEAM_CONNECTION', - 'NO_CONNECTION', -] as const +/** Harmonic documents per-user connection filtering as unsupported via the API. */ +export const HARMONIC_USER_CONNECTION_STATUSES = ['TEAM_CONNECTION', 'NO_CONNECTION'] as const /** Terminal states for a bulk email-enrichment job; `results` stays null until one is reached. */ export const HARMONIC_EMAIL_JOB_TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED']) export const HARMONIC_PERSON_INCLUDE_FIELDS = [ @@ -61,6 +58,7 @@ const PERSON_URN_PATTERN = /^urn:harmonic:person:[^\s]+$/ const SAVED_SEARCH_URN_PATTERN = /^urn:harmonic:saved_search:[^\s]+$/ const USER_URN_PATTERN = /^urn:harmonic:user:[^\s]+$/ const ENRICHMENT_URN_PATTERN = /^urn:harmonic:enrichment:[^\s]+$/ +const COMPANY_URN_PATTERN = /^urn:harmonic:company:[^\s]+$/ const COMPANY_OR_PERSON_URN_PATTERN = /^urn:harmonic:(company|person):[^\s]+$/ const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i @@ -230,14 +228,14 @@ function requireUserUrn(value: unknown): string { return normalized } +/** + * Passed through rather than checked against a fixed set. This value is display + * metadata that nothing downstream branches on, and Harmonic owns the enum — an + * allow-list would turn any value they add into a hard failure of the entire list, + * while the selector reading the same rows kept working. + */ function requireUserSavedSearchType(value: unknown): string { - const normalized = requireSavedSearchString(value, 'user_saved_search_type') - if (!HARMONIC_USER_SAVED_SEARCH_TYPES.has(normalized)) { - throw new Error( - 'Harmonic returned a people saved search with an invalid user_saved_search_type' - ) - } - return normalized + return requireSavedSearchString(value, 'user_saved_search_type') } function requireSavedSearchTimestamp(value: unknown, field: string): string { @@ -357,10 +355,6 @@ export function parsePersonUrns(value: unknown, paramName = 'personUrns'): strin return normalizePersonUrns(parseArrayParam(value, paramName), paramName) } -export function parsePersonIds(value: unknown): number[] { - return normalizePersonIds(parseArrayParam(value, 'personIds')) -} - export function clampPageSize(value: unknown): number { if (value === undefined || value === null || value === '') return HARMONIC_PAGE_SIZE_DEFAULT const parsed = parseSafeDecimalInteger(value, 'size') @@ -700,6 +694,9 @@ export function buildGetPersonUrl(personId: unknown, companyContextUrns: unknown `${HARMONIC_API_BASE}/persons/${encodeURIComponent(requireIdentifier(personId, 'personId'))}` ) for (const urn of uniqueStrings(parseArrayParam(companyContextUrns, 'companyContextUrns'))) { + if (!COMPANY_URN_PATTERN.test(urn)) { + throw new Error('Harmonic "companyContextUrns" must contain only company URNs') + } url.searchParams.append('company_context_urns', urn) } return url.toString() @@ -831,6 +828,39 @@ export function buildEnrichmentStatusUrl(enrichmentUrns: unknown): string { return url.toString() } +/** + * `linkedin.com/in/x`, `www.linkedin.com/in/x` and a trailing slash all name one + * profile, so a recognized profile folds to a host-and-path key. Regional + * subdomains (`uk.linkedin.com`) are deliberately left distinct: folding them + * would claim an equivalence Harmonic does not document. + * + * This key is only ever applied to a URL `normalizeLinkedinProfileUrl` already + * canonicalized — one with no query or fragment left. A URL forwarded verbatim for + * Harmonic to adjudicate keeps every component significant, so it deduplicates on + * its exact text; dropping the query or port there would silently discard a + * distinct identifier the caller asked to submit. + */ +function linkedinProfileKey(canonicalUrl: string): string { + try { + const parsed = new URL(canonicalUrl) + const host = parsed.hostname.toLowerCase().replace(/^www\./, '') + return `${host}${parsed.pathname.replace(/\/+$/, '')}` + } catch { + return canonicalUrl + } +} + +function dedupeByKey(entries: Array<{ url: string; key: string }>): string[] { + const seen = new Set() + const result: string[] = [] + for (const entry of entries) { + if (seen.has(entry.key)) continue + seen.add(entry.key) + result.push(entry.url) + } + return result +} + export function buildEmailEnrichmentJobBody( personUrns: unknown, personLinkedinUrls: unknown @@ -843,34 +873,49 @@ export function buildEmailEnrichmentJobBody( * for Harmonic to adjudicate. Only values that are not absolute http(s) URLs at * all are rejected here, because those are a local mistake, not a provider call. */ - const linkedinUrls = uniqueStrings(parseArrayParam(personLinkedinUrls, 'personLinkedinUrls')).map( - (value) => { + /** + * Blank and non-string entries are dropped first so `['']` reads as "no LinkedIn + * URLs supplied" rather than tripping the mutual-exclusivity check below or + * failing the per-URL parse. + */ + const rawLinkedinUrls = uniqueStrings(parseArrayParam(personLinkedinUrls, 'personLinkedinUrls')) + + /** + * Harmonic documents these as mutually exclusive — "Provide exactly one of the + * two arrays" — so sending both is rejected locally rather than letting the + * provider silently pick one and bill for it. This runs before any per-URL work + * so the clearer of the two errors wins when both problems are present. + */ + if (urns.length > 0 && rawLinkedinUrls.length > 0) { + throw new Error( + 'Harmonic Submit Email Enrichment Job accepts person URNs or LinkedIn URLs, not both' + ) + } + + /** + * Canonicalise first, then deduplicate. Harmonic canonicalizes and silently + * deduplicates server-side and reserves quota afterwards, so this is not what + * protects the bill — it keeps Sim's own 1-5000 accounting in step with the set + * Harmonic will actually accept, so a batch of equivalent URLs is not rejected + * locally for exceeding a cap it never reaches. + */ + const linkedinUrls = dedupeByKey( + rawLinkedinUrls.map((value) => { const normalized = normalizeLinkedinProfileUrl(value) - if (normalized) return normalized + if (normalized) return { url: normalized, key: linkedinProfileKey(normalized) } try { - const parsed = new URL(value) + const parsed = new URL(String(value)) if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error('unsupported scheme') } - return value + return { url: String(value), key: String(value) } } catch { throw new Error( 'Harmonic "personLinkedinUrls" must contain absolute http(s) URLs; Harmonic reports unmatched profiles in dropped' ) } - } + }) ) - - /** - * Harmonic documents these as mutually exclusive — "Provide exactly one of the - * two arrays" — so sending both is rejected locally rather than letting the - * provider silently pick one and bill for it. - */ - if (urns.length > 0 && linkedinUrls.length > 0) { - throw new Error( - 'Harmonic Submit Email Enrichment Job accepts person URNs or LinkedIn URLs, not both' - ) - } const identifiers = urns.length > 0 ? urns : linkedinUrls if (identifiers.length === 0) { throw new Error(