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..f1e690b019 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,25 @@ 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 ranked = withDates.map((r) => ({ + row: r, + rank: getMemberOrganizationSourceRank(r.source), + })) + const bestRank = Math.min(...ranked.map((r) => r.rank)) + const topSourceGroup = ranked.filter((r) => r.rank === bestRank).map((r) => r.row) + 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..f574c468e9 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, @@ -19,6 +19,13 @@ const logger = getServiceChildLogger('member-affiliations') type AffiliationItem = MemberOrganizationWithOverrides | IManualAffiliationData +const isManualAffiliation = (row: AffiliationItem): row is IManualAffiliationData => + 'segmentId' in row && !!row.segmentId + +const isMemberOrganizationWithOverrides = ( + row: AffiliationItem, +): row is MemberOrganizationWithOverrides => !isManualAffiliation(row) + async function prepareMemberOrganizationAffiliationTimeline( qx: QueryExecutor, memberId: string, @@ -53,7 +60,7 @@ async function prepareMemberOrganizationAffiliationTimeline( } // manual affiliations (identified by segmentId) always take highest precedence - const manualAffiliations = orgs.filter((row) => 'segmentId' in row && !!row.segmentId) + const manualAffiliations = orgs.filter(isManualAffiliation) if (manualAffiliations.length > 0) { if (manualAffiliations.length === 1) return manualAffiliations[0] // if multiple manual affiliations, pick the one with the longest date range @@ -81,11 +88,22 @@ 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 ranked = withDates.map((row) => ({ + row, + rank: getMemberOrganizationSourceRank( + isMemberOrganizationWithOverrides(row) ? row.source : undefined, + ), + })) + const bestRank = Math.min(...ranked.map((r) => r.rank)) + orgs = ranked.filter((r) => r.rank === bestRank).map((r) => r.row) + 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, - ) as MemberOrganizationWithOverrides[] + const memberOrgsOnly = orgs.filter(isMemberOrganizationWithOverrides) if (memberOrgsOnly.length >= 2) { const sortedByMembers = memberOrgsOnly.sort((a, b) => b.memberCount - a.memberCount) if (sortedByMembers[0].memberCount > sortedByMembers[1].memberCount) { @@ -93,7 +111,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 +261,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