Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions services/libs/common/src/member.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})

// ---------------------------------------------------------------------------
Expand Down
25 changes: 20 additions & 5 deletions services/libs/data-access-layer/src/affiliations/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -22,6 +22,7 @@ export interface IWorkExperienceResolution {
isPrimaryWorkExperience: boolean
memberCount: number
segmentId: string | null
source?: string | null
}

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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]
Comment thread
skwowet marked this conversation as resolved.
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -81,19 +88,30 @@ 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]
}
Comment thread
skwowet marked this conversation as resolved.

// 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) {
return sortedByMembers[0]
}
}

// 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)
}
}
Expand Down Expand Up @@ -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
Expand Down
Loading