Skip to content
Open
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
23 changes: 18 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,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,
)
Comment on lines +129 to +132
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This computes getMemberOrganizationSourceRank(r.source) twice per row (once for bestRank, once for filtering). Consider computing ranks once (e.g., map to {row, rank}) to avoid duplicated work and guarantee consistent ranking if the function changes.

Suggested change
const bestRank = Math.min(...withDates.map((r) => getMemberOrganizationSourceRank(r.source)))
const topSourceGroup = withDates.filter(
(r) => getMemberOrganizationSourceRank(r.source) === bestRank,
)
const rankedWithDates = withDates.map((row) => ({
row,
rank: getMemberOrganizationSourceRank(row.source),
}))
const bestRank = Math.min(...rankedWithDates.map(({ rank }) => rank))
const topSourceGroup = rankedWithDates
.filter(({ rank }) => rank === bestRank)
.map(({ row }) => row)

Copilot uses AI. Check for mistakes.
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 Down Expand Up @@ -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]
Comment thread
skwowet marked this conversation as resolved.
Comment on lines +86 to +90
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sourceRank relies on an unchecked cast to MemberOrganizationWithOverrides to access .source. Since AffiliationItem is a union, this hides type issues and can mask undefined at runtime. Prefer a type guard (e.g. check 'source' in row) or narrow to member-organization rows before ranking sources.

Copilot uses AI. Check for mistakes.
}
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,
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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
Expand Down
Loading