Skip to content

Commit 3232b22

Browse files
authored
feat: source-priority tiebreaker in affiliation timeline builder (CM-1106) (#4055)
Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
1 parent 574713f commit 3232b22

4 files changed

Lines changed: 141 additions & 12 deletions

File tree

services/libs/common/src/member.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import merge from 'lodash.merge'
22
import ldSum from 'lodash.sum'
33

4+
import { OrganizationSource } from '@crowd/types'
5+
46
/* eslint-disable @typescript-eslint/no-explicit-any */
57

68
export async function setAttributesDefaultValues(
@@ -79,3 +81,10 @@ export const calculateReach = (oldReach: any, newReach: any): { total: number }
7981
out.total = ldSum(Object.values(out))
8082
return out
8183
}
84+
85+
export function getMemberOrganizationSourceRank(source: string | null | undefined): number {
86+
if (source === OrganizationSource.UI) return 0
87+
if (source === OrganizationSource.EMAIL_DOMAIN) return 1
88+
if (source?.startsWith('enrichment-')) return 2
89+
return 3
90+
}

services/libs/data-access-layer/src/affiliations/__tests__/affiliations.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,92 @@ describe('selectPrimaryWorkExperience', () => {
182182
const result = selectPrimaryWorkExperience([shortRange, longRange])
183183
expect(result.organizationName).toBe('LongRange')
184184
})
185+
186+
it('email-domain beats enrichment when both are dated', () => {
187+
const enrichment = makeRow({
188+
organizationId: 'enrichment',
189+
organizationName: 'Enrichment Org',
190+
dateStart: '2020-01-01',
191+
source: 'enrichment-progai',
192+
})
193+
const emailDomain = makeRow({
194+
organizationId: 'email',
195+
organizationName: 'Email Org',
196+
dateStart: '2020-01-01',
197+
source: 'email-domain',
198+
})
199+
expect(selectPrimaryWorkExperience([enrichment, emailDomain]).organizationName).toBe(
200+
'Email Org',
201+
)
202+
})
203+
204+
it('ui beats email-domain when both are dated', () => {
205+
const emailDomain = makeRow({
206+
organizationId: 'email',
207+
organizationName: 'Email Org',
208+
dateStart: '2020-01-01',
209+
source: 'email-domain',
210+
})
211+
const ui = makeRow({
212+
organizationId: 'ui',
213+
organizationName: 'UI Org',
214+
dateStart: '2020-01-01',
215+
source: 'ui',
216+
})
217+
expect(selectPrimaryWorkExperience([emailDomain, ui]).organizationName).toBe('UI Org')
218+
})
219+
220+
it('ui beats enrichment when both are dated', () => {
221+
const enrichment = makeRow({
222+
organizationId: 'enrichment',
223+
organizationName: 'Enrichment Org',
224+
dateStart: '2020-01-01',
225+
source: 'enrichment-clearbit',
226+
})
227+
const ui = makeRow({
228+
organizationId: 'ui',
229+
organizationName: 'UI Org',
230+
dateStart: '2020-01-01',
231+
source: 'ui',
232+
})
233+
expect(selectPrimaryWorkExperience([enrichment, ui]).organizationName).toBe('UI Org')
234+
})
235+
236+
it('falls through to member count when source tiers are equal', () => {
237+
const small = makeRow({
238+
organizationId: 'small',
239+
organizationName: 'Small Enrichment',
240+
dateStart: '2020-01-01',
241+
memberCount: 10,
242+
source: 'enrichment-progai',
243+
})
244+
const large = makeRow({
245+
organizationId: 'large',
246+
organizationName: 'Large Enrichment',
247+
dateStart: '2020-01-01',
248+
memberCount: 100,
249+
source: 'enrichment-progai',
250+
})
251+
expect(selectPrimaryWorkExperience([small, large]).organizationName).toBe('Large Enrichment')
252+
})
253+
254+
it('undated rows are not affected by source priority — dated enrichment beats undated email-domain', () => {
255+
const undatedEmailDomain = makeRow({
256+
organizationId: 'email',
257+
organizationName: 'Email Org',
258+
dateStart: null,
259+
source: 'email-domain',
260+
})
261+
const datedEnrichment = makeRow({
262+
organizationId: 'enrichment',
263+
organizationName: 'Enrichment Org',
264+
dateStart: '2020-01-01',
265+
source: 'enrichment-progai',
266+
})
267+
expect(
268+
selectPrimaryWorkExperience([undatedEmailDomain, datedEnrichment]).organizationName,
269+
).toBe('Enrichment Org')
270+
})
185271
})
186272

187273
// ---------------------------------------------------------------------------

services/libs/data-access-layer/src/affiliations/index.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getLongestDateRange } from '@crowd/common'
1+
import { getLongestDateRange, getMemberOrganizationSourceRank } from '@crowd/common'
22
import { IMemberOrganization } from '@crowd/types'
33

44
import { BLACKLISTED_MEMBER_TITLES } from '../members/base'
@@ -22,6 +22,7 @@ export interface IWorkExperienceResolution {
2222
isPrimaryWorkExperience: boolean
2323
memberCount: number
2424
segmentId: string | null
25+
source?: string | null
2526
}
2627

2728
/**
@@ -58,7 +59,8 @@ export async function findWorkExperiencesBulk(
5859
mo."createdAt",
5960
COALESCE(ovr."isPrimaryWorkExperience", false) AS "isPrimaryWorkExperience",
6061
COALESCE(a.total_count, 0) AS "memberCount",
61-
NULL::text AS "segmentId"
62+
NULL::text AS "segmentId",
63+
mo."source"
6264
FROM "memberOrganizations" mo
6365
JOIN organizations o ON mo."organizationId" = o.id
6466
LEFT JOIN "memberOrganizationAffiliationOverrides" ovr ON ovr."memberOrganizationId" = mo.id
@@ -92,7 +94,8 @@ export async function findManualAffiliationsBulk(
9294
NULL::timestamptz AS "createdAt",
9395
false AS "isPrimaryWorkExperience",
9496
0 AS "memberCount",
95-
msa."segmentId"
97+
msa."segmentId",
98+
NULL AS "source"
9699
FROM "memberSegmentAffiliations" msa
97100
JOIN organizations o ON msa."organizationId" = o.id
98101
WHERE msa."memberId" IN ($(memberIds:csv))
@@ -121,13 +124,25 @@ export function selectPrimaryWorkExperience(orgs: IWorkExperienceResolution[]) {
121124
const withDates = orgs.filter((r) => r.dateStart)
122125
if (withDates.length === 1) return withDates[0]
123126

124-
// 4. Org with strictly more members wins; if tied, fall through
127+
// 4. Among dated rows, pick the best source tier (ui > email-domain > enrichment-*)
128+
if (withDates.length > 1) {
129+
const ranked = withDates.map((r) => ({
130+
row: r,
131+
rank: getMemberOrganizationSourceRank(r.source),
132+
}))
133+
const bestRank = Math.min(...ranked.map((r) => r.rank))
134+
const topSourceGroup = ranked.filter((r) => r.rank === bestRank).map((r) => r.row)
135+
if (topSourceGroup.length === 1) return topSourceGroup[0]
136+
orgs = topSourceGroup
137+
}
138+
139+
// 5. Org with strictly more members wins; if tied, fall through
125140
const sorted = [...orgs].sort((a, b) => b.memberCount - a.memberCount)
126141
if (sorted.length >= 2 && sorted[0].memberCount > sorted[1].memberCount) {
127142
return sorted[0]
128143
}
129144

130-
// 5. Longest date range as final tiebreaker
145+
// 6. Longest date range as final tiebreaker
131146
return getLongestDateRange(
132147
orgs as unknown as IMemberOrganization[],
133148
) as unknown as IWorkExperienceResolution

services/libs/data-access-layer/src/member-organization-affiliation/index.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import _ from 'lodash'
22
import { v4 as uuid } from 'uuid'
33

4-
import { getLongestDateRange } from '@crowd/common'
4+
import { getLongestDateRange, getMemberOrganizationSourceRank } from '@crowd/common'
55
import { getServiceChildLogger } from '@crowd/logging'
66
import {
77
IChangeAffiliationOverrideData,
@@ -19,6 +19,13 @@ const logger = getServiceChildLogger('member-affiliations')
1919

2020
type AffiliationItem = MemberOrganizationWithOverrides | IManualAffiliationData
2121

22+
const isManualAffiliation = (row: AffiliationItem): row is IManualAffiliationData =>
23+
'segmentId' in row && !!row.segmentId
24+
25+
const isMemberOrganizationWithOverrides = (
26+
row: AffiliationItem,
27+
): row is MemberOrganizationWithOverrides => !isManualAffiliation(row)
28+
2229
async function prepareMemberOrganizationAffiliationTimeline(
2330
qx: QueryExecutor,
2431
memberId: string,
@@ -53,7 +60,7 @@ async function prepareMemberOrganizationAffiliationTimeline(
5360
}
5461

5562
// manual affiliations (identified by segmentId) always take highest precedence
56-
const manualAffiliations = orgs.filter((row) => 'segmentId' in row && !!row.segmentId)
63+
const manualAffiliations = orgs.filter(isManualAffiliation)
5764
if (manualAffiliations.length > 0) {
5865
if (manualAffiliations.length === 1) return manualAffiliations[0]
5966
// if multiple manual affiliations, pick the one with the longest date range
@@ -81,19 +88,30 @@ async function prepareMemberOrganizationAffiliationTimeline(
8188
return withDates[0]
8289
}
8390

84-
// 2. get the two orgs with the most members, and return the one with the most members if there's no draw
91+
// 2. among dated rows, pick the best source tier (ui > email-domain > enrichment-*)
92+
if (withDates.length > 1) {
93+
const ranked = withDates.map((row) => ({
94+
row,
95+
rank: getMemberOrganizationSourceRank(
96+
isMemberOrganizationWithOverrides(row) ? row.source : undefined,
97+
),
98+
}))
99+
const bestRank = Math.min(...ranked.map((r) => r.rank))
100+
orgs = ranked.filter((r) => r.rank === bestRank).map((r) => r.row)
101+
if (orgs.length === 1) return orgs[0]
102+
}
103+
104+
// 3. get the two orgs with the most members, and return the one with the most members if there's no draw
85105
// only compare member orgs (manual affiliations don't have memberCount)
86-
const memberOrgsOnly = orgs.filter(
87-
(row: AffiliationItem) => 'segmentId' in row && !!row.segmentId,
88-
) as MemberOrganizationWithOverrides[]
106+
const memberOrgsOnly = orgs.filter(isMemberOrganizationWithOverrides)
89107
if (memberOrgsOnly.length >= 2) {
90108
const sortedByMembers = memberOrgsOnly.sort((a, b) => b.memberCount - a.memberCount)
91109
if (sortedByMembers[0].memberCount > sortedByMembers[1].memberCount) {
92110
return sortedByMembers[0]
93111
}
94112
}
95113

96-
// 3. there's a draw, return the one with the longer date range
114+
// 4. there's a draw, return the one with the longer date range
97115
return getLongestDateRange(orgs)
98116
}
99117
}
@@ -243,6 +261,7 @@ async function prepareMemberOrganizationAffiliationTimeline(
243261
mo."dateStart",
244262
mo."dateEnd",
245263
mo."createdAt",
264+
mo."source",
246265
coalesce(ovr."isPrimaryWorkExperience", false) as "isPrimaryWorkExperience",
247266
coalesce(a.total_count, 0) as "memberCount"
248267
FROM "memberOrganizations" mo

0 commit comments

Comments
 (0)