From 21a5b554964355a0ad5de1502a463c93c776e769 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 20 Aug 2026 17:30:08 -0700 Subject: [PATCH 1/3] fix(harmonic): correct the destructive-clear copy and stop double-billing enrichment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #6902, from a final validation pass against Harmonic's OpenAPI and API reference. No endpoint, method, or response mapping changed. - The `personUrns` field told users and the LLM that Clear Net-New Results "clears everything when omitted". That is the raw provider behavior the clearScope guard was added to block; omitting it now throws. The field is shared across three operations, so the wrong sentence was being served as guidance on all of them. - Bulk email enrichment deduplicated LinkedIn URLs before canonicalising them, so `.../in/foo?utm_source=x` and `.../in/foo` were submitted as two people. Harmonic bills per submitted entry, so this spent quota twice and double-counted against the 5,000 cap. Deduplicate after canonicalising. - The two documented bulk-enrichment failures carry a code in `error` and no message anywhere, so quota exhaustion surfaced as "Request failed with status 429". Render the code with its counters instead. Gated on those counters being present: `extractErrorMessage` without an explicit id walks every extractor in order, and claiming a bare `error` key swallowed OAuth's `error_description`. - An enrichment 404 whose detail carries only the URN no longer discards it. - Report the identifier conflict before complaining about an individual URL. - Validate `companyContextUrns` as company URNs, like every other URN param. Forward-compat: `user_saved_search_type` is passed through rather than checked against a fixed set. It is display metadata nothing branches on, and Harmonic owns the enum — an allow-list turned any value they add into a hard failure of the whole list while the selector reading the same rows kept working. Also drops `USER_CONNECTION`, which Harmonic documents as unsupported via the API, removes three superseded types and one dead helper, and extends the "credential never reaches a URL or body" assertion from 4 tools to all 13. --- apps/sim/blocks/blocks/harmonic.ts | 4 +- apps/sim/tools/error-extractors.ts | 22 ++++- apps/sim/tools/harmonic/harmonic.test.ts | 110 ++++++++++++++++++++++- apps/sim/tools/harmonic/types.ts | 18 ---- apps/sim/tools/harmonic/utils.ts | 69 +++++++------- 5 files changed, 168 insertions(+), 55 deletions(-) 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..9602d346436 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,62 @@ describe('Harmonic email enrichment', () => { ).toThrow('must contain absolute http(s) URLs') }) + it('deduplicates LinkedIn URLs after canonicalisation so quota is not spent twice', () => { + 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', + ], + }) + ).toEqual({ person_linkedin_urls: ['https://www.linkedin.com/in/ada'] }) + }) + + 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..a4226628239 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() @@ -843,34 +840,42 @@ 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) => { + const rawLinkedinUrls = 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: `.../in/foo?utm_source=x` and `.../in/foo` + * are the same person, and Harmonic bills each submitted entry, so deduplicating + * the raw strings would spend quota twice and double-count against the cap. + */ + const linkedinUrls = uniqueStrings( + rawLinkedinUrls.map((value) => { const normalized = normalizeLinkedinProfileUrl(value) if (normalized) return 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 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( From e26cb14464ec7d5ac9c895ce03407262ba92f664 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 20 Aug 2026 17:46:29 -0700 Subject: [PATCH 2/3] fix(harmonic): fold equivalent profile URLs and stop blank entries failing a batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round on #6915. - Blank and non-string `personLinkedinUrls` entries are dropped before the mutual-exclusivity check. Moving the filter after per-URL validation meant a list like `['']` alongside person URNs reported "not both" — naming a conflict the caller never created — or failed the URL parse instead of reading as absent. - Deduplicate on a profile key rather than the canonical string, so `linkedin.com/in/x`, `www.linkedin.com/in/x` and a trailing slash count once. Harmonic canonicalizes and silently deduplicates server-side and reserves quota afterwards, so this does not change what is billed; it keeps Sim's own 1-5000 accounting in step with the set Harmonic accepts, so a batch of equivalent URLs is not rejected locally for a cap it never reaches. Regional subdomains stay distinct: folding `uk.linkedin.com` into `www.` would assert an equivalence Harmonic does not document, and the URL kept for display must remain the one the caller supplied. --- apps/sim/tools/harmonic/harmonic.test.ts | 28 ++++++++++++++- apps/sim/tools/harmonic/utils.ts | 45 +++++++++++++++++++++--- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/apps/sim/tools/harmonic/harmonic.test.ts b/apps/sim/tools/harmonic/harmonic.test.ts index 9602d346436..7784b4e79c0 100644 --- a/apps/sim/tools/harmonic/harmonic.test.ts +++ b/apps/sim/tools/harmonic/harmonic.test.ts @@ -1150,18 +1150,44 @@ describe('Harmonic email enrichment', () => { ).toThrow('must contain absolute http(s) URLs') }) - it('deduplicates LinkedIn URLs after canonicalisation so quota is not spent twice', () => { + 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 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, { diff --git a/apps/sim/tools/harmonic/utils.ts b/apps/sim/tools/harmonic/utils.ts index a4226628239..034c61e50f6 100644 --- a/apps/sim/tools/harmonic/utils.ts +++ b/apps/sim/tools/harmonic/utils.ts @@ -828,6 +828,34 @@ 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. Regional subdomains (`uk.linkedin.com`) are deliberately left distinct: + * folding them would claim an equivalence Harmonic does not document, and the + * canonical URL kept for display must stay the one the caller supplied. + */ +function linkedinProfileKey(url: string): string { + try { + const parsed = new URL(url) + const host = parsed.hostname.toLowerCase().replace(/^www\./, '') + return `${host}${parsed.pathname.replace(/\/+$/, '')}` + } catch { + return url + } +} + +function dedupeLinkedinProfiles(urls: string[]): string[] { + const seen = new Set() + const result: string[] = [] + for (const url of urls) { + const key = linkedinProfileKey(url) + if (seen.has(key)) continue + seen.add(key) + result.push(url) + } + return result +} + export function buildEmailEnrichmentJobBody( personUrns: unknown, personLinkedinUrls: unknown @@ -840,7 +868,12 @@ 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 rawLinkedinUrls = parseArrayParam(personLinkedinUrls, 'personLinkedinUrls') + /** + * 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 @@ -855,11 +888,13 @@ export function buildEmailEnrichmentJobBody( } /** - * Canonicalise first, then deduplicate: `.../in/foo?utm_source=x` and `.../in/foo` - * are the same person, and Harmonic bills each submitted entry, so deduplicating - * the raw strings would spend quota twice and double-count against the cap. + * 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 = uniqueStrings( + const linkedinUrls = dedupeLinkedinProfiles( rawLinkedinUrls.map((value) => { const normalized = normalizeLinkedinProfileUrl(value) if (normalized) return normalized From 808d015adc396bf262693db4cf812bf5259e3ba6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 20 Aug 2026 17:54:19 -0700 Subject: [PATCH 3/3] fix(harmonic): fold only recognized profile URLs, never pass-through ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round on #6915. The profile key added last round was applied to every entry, but it is built from host and path alone. A URL forwarded verbatim for Harmonic to adjudicate keeps its query, port and fragment significant, so two distinct identifiers collapsed to one key and the later one was dropped before Harmonic ever saw it. The key now applies only to a URL `normalizeLinkedinProfileUrl` already canonicalized — where the query and fragment are gone by construction, so folding host and trailing slash is safe. Anything passed through deduplicates on its exact text. --- apps/sim/tools/harmonic/harmonic.test.ts | 28 +++++++++++++++++++ apps/sim/tools/harmonic/utils.ts | 35 ++++++++++++++---------- 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/apps/sim/tools/harmonic/harmonic.test.ts b/apps/sim/tools/harmonic/harmonic.test.ts index 7784b4e79c0..66d21f1ae56 100644 --- a/apps/sim/tools/harmonic/harmonic.test.ts +++ b/apps/sim/tools/harmonic/harmonic.test.ts @@ -1165,6 +1165,34 @@ describe('Harmonic email enrichment', () => { ).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, { diff --git a/apps/sim/tools/harmonic/utils.ts b/apps/sim/tools/harmonic/utils.ts index 034c61e50f6..dc3b6fe0b36 100644 --- a/apps/sim/tools/harmonic/utils.ts +++ b/apps/sim/tools/harmonic/utils.ts @@ -830,28 +830,33 @@ export function buildEnrichmentStatusUrl(enrichmentUrns: unknown): string { /** * `linkedin.com/in/x`, `www.linkedin.com/in/x` and a trailing slash all name one - * profile. Regional subdomains (`uk.linkedin.com`) are deliberately left distinct: - * folding them would claim an equivalence Harmonic does not document, and the - * canonical URL kept for display must stay the one the caller supplied. + * 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(url: string): string { +function linkedinProfileKey(canonicalUrl: string): string { try { - const parsed = new URL(url) + const parsed = new URL(canonicalUrl) const host = parsed.hostname.toLowerCase().replace(/^www\./, '') return `${host}${parsed.pathname.replace(/\/+$/, '')}` } catch { - return url + return canonicalUrl } } -function dedupeLinkedinProfiles(urls: string[]): string[] { +function dedupeByKey(entries: Array<{ url: string; key: string }>): string[] { const seen = new Set() const result: string[] = [] - for (const url of urls) { - const key = linkedinProfileKey(url) - if (seen.has(key)) continue - seen.add(key) - result.push(url) + for (const entry of entries) { + if (seen.has(entry.key)) continue + seen.add(entry.key) + result.push(entry.url) } return result } @@ -894,16 +899,16 @@ export function buildEmailEnrichmentJobBody( * Harmonic will actually accept, so a batch of equivalent URLs is not rejected * locally for exceeding a cap it never reaches. */ - const linkedinUrls = dedupeLinkedinProfiles( + 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(String(value)) if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error('unsupported scheme') } - return String(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'