diff --git a/services/libs/common/src/member.ts b/services/libs/common/src/member.ts index 58d64a6b03..2ca0d2c455 100644 --- a/services/libs/common/src/member.ts +++ b/services/libs/common/src/member.ts @@ -1,6 +1,8 @@ import merge from 'lodash.merge' import ldSum from 'lodash.sum' +import { OrganizationSource } from '@crowd/types' + /* eslint-disable @typescript-eslint/no-explicit-any */ export async function setAttributesDefaultValues( @@ -79,3 +81,10 @@ export const calculateReach = (oldReach: any, newReach: any): { total: number } out.total = ldSum(Object.values(out)) return out } + +export function getMemberOrganizationSourceRank(source: string | null | undefined): number { + if (source === OrganizationSource.UI) return 0 + if (source === OrganizationSource.EMAIL_DOMAIN) return 1 + if (source?.startsWith('enrichment-')) return 2 + return 3 +} diff --git a/services/libs/data-access-layer/src/affiliations/__tests__/affiliations.test.ts b/services/libs/data-access-layer/src/affiliations/__tests__/affiliations.test.ts index 4edf77b0cb..d6acaef53d 100644 --- a/services/libs/data-access-layer/src/affiliations/__tests__/affiliations.test.ts +++ b/services/libs/data-access-layer/src/affiliations/__tests__/affiliations.test.ts @@ -182,6 +182,92 @@ describe('selectPrimaryWorkExperience', () => { const result = selectPrimaryWorkExperience([shortRange, longRange]) expect(result.organizationName).toBe('LongRange') }) + + it('email-domain beats enrichment when both are dated', () => { + const enrichment = makeRow({ + organizationId: 'enrichment', + organizationName: 'Enrichment Org', + dateStart: '2020-01-01', + source: 'enrichment-progai', + }) + const emailDomain = makeRow({ + organizationId: 'email', + organizationName: 'Email Org', + dateStart: '2020-01-01', + source: 'email-domain', + }) + expect(selectPrimaryWorkExperience([enrichment, emailDomain]).organizationName).toBe( + 'Email Org', + ) + }) + + it('ui beats email-domain when both are dated', () => { + const emailDomain = makeRow({ + organizationId: 'email', + organizationName: 'Email Org', + dateStart: '2020-01-01', + source: 'email-domain', + }) + const ui = makeRow({ + organizationId: 'ui', + organizationName: 'UI Org', + dateStart: '2020-01-01', + source: 'ui', + }) + expect(selectPrimaryWorkExperience([emailDomain, ui]).organizationName).toBe('UI Org') + }) + + it('ui beats enrichment when both are dated', () => { + const enrichment = makeRow({ + organizationId: 'enrichment', + organizationName: 'Enrichment Org', + dateStart: '2020-01-01', + source: 'enrichment-clearbit', + }) + const ui = makeRow({ + organizationId: 'ui', + organizationName: 'UI Org', + dateStart: '2020-01-01', + source: 'ui', + }) + expect(selectPrimaryWorkExperience([enrichment, ui]).organizationName).toBe('UI Org') + }) + + it('falls through to member count when source tiers are equal', () => { + const small = makeRow({ + organizationId: 'small', + organizationName: 'Small Enrichment', + dateStart: '2020-01-01', + memberCount: 10, + source: 'enrichment-progai', + }) + const large = makeRow({ + organizationId: 'large', + organizationName: 'Large Enrichment', + dateStart: '2020-01-01', + memberCount: 100, + source: 'enrichment-progai', + }) + expect(selectPrimaryWorkExperience([small, large]).organizationName).toBe('Large Enrichment') + }) + + it('undated rows are not affected by source priority — dated enrichment beats undated email-domain', () => { + const undatedEmailDomain = makeRow({ + organizationId: 'email', + organizationName: 'Email Org', + dateStart: null, + source: 'email-domain', + }) + const datedEnrichment = makeRow({ + organizationId: 'enrichment', + organizationName: 'Enrichment Org', + dateStart: '2020-01-01', + source: 'enrichment-progai', + }) + expect( + selectPrimaryWorkExperience([undatedEmailDomain, datedEnrichment]).organizationName, + ).toBe('Enrichment Org') + }) }) // --------------------------------------------------------------------------- diff --git a/services/libs/data-access-layer/src/affiliations/index.ts b/services/libs/data-access-layer/src/affiliations/index.ts index 7f669885c8..85c8d449c1 100644 --- a/services/libs/data-access-layer/src/affiliations/index.ts +++ b/services/libs/data-access-layer/src/affiliations/index.ts @@ -1,4 +1,4 @@ -import { getLongestDateRange } from '@crowd/common' +import { getLongestDateRange, getMemberOrganizationSourceRank } from '@crowd/common' import { IMemberOrganization } from '@crowd/types' import { BLACKLISTED_MEMBER_TITLES } from '../members/base' @@ -22,6 +22,7 @@ export interface IWorkExperienceResolution { isPrimaryWorkExperience: boolean memberCount: number segmentId: string | null + source?: string | null } /** @@ -58,7 +59,8 @@ export async function findWorkExperiencesBulk( mo."createdAt", COALESCE(ovr."isPrimaryWorkExperience", false) AS "isPrimaryWorkExperience", COALESCE(a.total_count, 0) AS "memberCount", - NULL::text AS "segmentId" + NULL::text AS "segmentId", + mo."source" FROM "memberOrganizations" mo JOIN organizations o ON mo."organizationId" = o.id LEFT JOIN "memberOrganizationAffiliationOverrides" ovr ON ovr."memberOrganizationId" = mo.id @@ -92,7 +94,8 @@ export async function findManualAffiliationsBulk( NULL::timestamptz AS "createdAt", false AS "isPrimaryWorkExperience", 0 AS "memberCount", - msa."segmentId" + msa."segmentId", + NULL AS "source" FROM "memberSegmentAffiliations" msa JOIN organizations o ON msa."organizationId" = o.id WHERE msa."memberId" IN ($(memberIds:csv)) @@ -121,13 +124,23 @@ export function selectPrimaryWorkExperience(orgs: IWorkExperienceResolution[]) { const withDates = orgs.filter((r) => r.dateStart) if (withDates.length === 1) return withDates[0] - // 4. Org with strictly more members wins; if tied, fall through + // 4. Among dated rows, pick the best source tier (ui > email-domain > enrichment-*) + if (withDates.length > 1) { + const bestRank = Math.min(...withDates.map((r) => getMemberOrganizationSourceRank(r.source))) + const topSourceGroup = withDates.filter( + (r) => getMemberOrganizationSourceRank(r.source) === bestRank, + ) + if (topSourceGroup.length === 1) return topSourceGroup[0] + orgs = topSourceGroup + } + + // 5. Org with strictly more members wins; if tied, fall through const sorted = [...orgs].sort((a, b) => b.memberCount - a.memberCount) if (sorted.length >= 2 && sorted[0].memberCount > sorted[1].memberCount) { return sorted[0] } - // 5. Longest date range as final tiebreaker + // 6. Longest date range as final tiebreaker return getLongestDateRange( orgs as unknown as IMemberOrganization[], ) as unknown as IWorkExperienceResolution diff --git a/services/libs/data-access-layer/src/member-organization-affiliation/index.ts b/services/libs/data-access-layer/src/member-organization-affiliation/index.ts index b8501bfc62..4c7ef95af5 100644 --- a/services/libs/data-access-layer/src/member-organization-affiliation/index.ts +++ b/services/libs/data-access-layer/src/member-organization-affiliation/index.ts @@ -1,7 +1,7 @@ import _ from 'lodash' import { v4 as uuid } from 'uuid' -import { getLongestDateRange } from '@crowd/common' +import { getLongestDateRange, getMemberOrganizationSourceRank } from '@crowd/common' import { getServiceChildLogger } from '@crowd/logging' import { IChangeAffiliationOverrideData, @@ -81,7 +81,16 @@ async function prepareMemberOrganizationAffiliationTimeline( return withDates[0] } - // 2. get the two orgs with the most members, and return the one with the most members if there's no draw + // 2. among dated rows, pick the best source tier (ui > email-domain > enrichment-*) + if (withDates.length > 1) { + const sourceRank = (row: AffiliationItem) => + getMemberOrganizationSourceRank((row as MemberOrganizationWithOverrides).source) + const bestRank = Math.min(...withDates.map(sourceRank)) + orgs = withDates.filter((row) => sourceRank(row) === bestRank) + if (orgs.length === 1) return orgs[0] + } + + // 3. get the two orgs with the most members, and return the one with the most members if there's no draw // only compare member orgs (manual affiliations don't have memberCount) const memberOrgsOnly = orgs.filter( (row: AffiliationItem) => 'segmentId' in row && !!row.segmentId, @@ -93,7 +102,7 @@ async function prepareMemberOrganizationAffiliationTimeline( } } - // 3. there's a draw, return the one with the longer date range + // 4. there's a draw, return the one with the longer date range return getLongestDateRange(orgs) } } @@ -243,6 +252,7 @@ async function prepareMemberOrganizationAffiliationTimeline( mo."dateStart", mo."dateEnd", mo."createdAt", + mo."source", coalesce(ovr."isPrimaryWorkExperience", false) as "isPrimaryWorkExperience", coalesce(a.total_count, 0) as "memberCount" FROM "memberOrganizations" mo