From 25bcf9a0733a30ed2f427d39b8c382058ac93c2d Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:41:12 +0530 Subject: [PATCH 1/7] feat: source-priority tiebreaker in affiliation timeline builder Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com> --- services/libs/common/src/member.ts | 9 +++++++++ .../src/member-organization-affiliation/index.ts | 16 +++++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/services/libs/common/src/member.ts b/services/libs/common/src/member.ts index 58d64a6b03..7687807c47 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 | 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/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 From 97f5dd7bc7c5c270ce82af31fa30b0dcf6ce0d61 Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Fri, 24 Apr 2026 23:38:40 +0530 Subject: [PATCH 2/7] feat: update member organization source rank handling Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com> --- services/libs/common/src/member.ts | 2 +- .../src/affiliations/index.ts | 21 ++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/services/libs/common/src/member.ts b/services/libs/common/src/member.ts index 7687807c47..2ca0d2c455 100644 --- a/services/libs/common/src/member.ts +++ b/services/libs/common/src/member.ts @@ -82,7 +82,7 @@ export const calculateReach = (oldReach: any, newReach: any): { total: number } return out } -export function getMemberOrganizationSourceRank(source: string | undefined): number { +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 diff --git a/services/libs/data-access-layer/src/affiliations/index.ts b/services/libs/data-access-layer/src/affiliations/index.ts index 7f669885c8..8012dcdb8b 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,21 @@ 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 From f29056f7600aa09b8745d71b474a360c7f30beae Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:10:00 +0530 Subject: [PATCH 3/7] style: format code for better readability Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com> --- services/libs/data-access-layer/src/affiliations/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/libs/data-access-layer/src/affiliations/index.ts b/services/libs/data-access-layer/src/affiliations/index.ts index 8012dcdb8b..2ecad00d2d 100644 --- a/services/libs/data-access-layer/src/affiliations/index.ts +++ b/services/libs/data-access-layer/src/affiliations/index.ts @@ -127,7 +127,9 @@ export function selectPrimaryWorkExperience(orgs: IWorkExperienceResolution[]) { // 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) + const topSourceGroup = withDates.filter( + (r) => getMemberOrganizationSourceRank(r.source) === bestRank, + ) if (topSourceGroup.length === 1) return topSourceGroup[0] orgs = topSourceGroup } From 99097ff3f5bd083b6288be3d68f9cd7aba2a07e0 Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:14:53 +0530 Subject: [PATCH 4/7] feat: make source property optional in work experience interface Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com> --- .../__tests__/affiliations.test.ts | 84 +++++++++++++++++++ .../src/affiliations/index.ts | 2 +- 2 files changed, 85 insertions(+), 1 deletion(-) 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..043d57c81f 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,90 @@ 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 2ecad00d2d..85c8d449c1 100644 --- a/services/libs/data-access-layer/src/affiliations/index.ts +++ b/services/libs/data-access-layer/src/affiliations/index.ts @@ -22,7 +22,7 @@ export interface IWorkExperienceResolution { isPrimaryWorkExperience: boolean memberCount: number segmentId: string | null - source: string | null + source?: string | null } /** From 2004aa218c79da4acfb8a9846e650a67932b733c Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:53:03 +0530 Subject: [PATCH 5/7] fix: code format errors Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com> --- .../src/affiliations/__tests__/affiliations.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 043d57c81f..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 @@ -196,7 +196,9 @@ describe('selectPrimaryWorkExperience', () => { dateStart: '2020-01-01', source: 'email-domain', }) - expect(selectPrimaryWorkExperience([enrichment, emailDomain]).organizationName).toBe('Email Org') + expect(selectPrimaryWorkExperience([enrichment, emailDomain]).organizationName).toBe( + 'Email Org', + ) }) it('ui beats email-domain when both are dated', () => { @@ -262,9 +264,9 @@ describe('selectPrimaryWorkExperience', () => { dateStart: '2020-01-01', source: 'enrichment-progai', }) - expect(selectPrimaryWorkExperience([undatedEmailDomain, datedEnrichment]).organizationName).toBe( - 'Enrichment Org', - ) + expect( + selectPrimaryWorkExperience([undatedEmailDomain, datedEnrichment]).organizationName, + ).toBe('Enrichment Org') }) }) From 8a6c18fda75d30a91f24ada52a189e1337fb507a Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:33:43 +0530 Subject: [PATCH 6/7] fix: resolve pr comments from ai bots Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com> --- .../src/affiliations/index.ts | 7 +++--- .../member-organization-affiliation/index.ts | 25 +++++++++++++------ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/services/libs/data-access-layer/src/affiliations/index.ts b/services/libs/data-access-layer/src/affiliations/index.ts index 85c8d449c1..c18d7f8998 100644 --- a/services/libs/data-access-layer/src/affiliations/index.ts +++ b/services/libs/data-access-layer/src/affiliations/index.ts @@ -126,10 +126,9 @@ export function selectPrimaryWorkExperience(orgs: IWorkExperienceResolution[]) { // 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, - ) + 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 } 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 4c7ef95af5..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 @@ -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 @@ -83,18 +90,20 @@ async function prepareMemberOrganizationAffiliationTimeline( // 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) + 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) { From c61a950c77c9e56bdfe8d16911cf512060b5f726 Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Sun, 26 Apr 2026 19:42:20 +0530 Subject: [PATCH 7/7] style: improve code formatting for better readability Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com> --- services/libs/data-access-layer/src/affiliations/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/libs/data-access-layer/src/affiliations/index.ts b/services/libs/data-access-layer/src/affiliations/index.ts index c18d7f8998..f1e690b019 100644 --- a/services/libs/data-access-layer/src/affiliations/index.ts +++ b/services/libs/data-access-layer/src/affiliations/index.ts @@ -126,7 +126,10 @@ export function selectPrimaryWorkExperience(orgs: IWorkExperienceResolution[]) { // 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 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]