Skip to content

Commit 5850523

Browse files
authored
fix: prefer company over uni on overlap during affiliation (CM-1216) (#4188)
Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
1 parent 01cc843 commit 5850523

6 files changed

Lines changed: 241 additions & 41 deletions

File tree

services/apps/data_sink_worker/src/service/activity.service.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1506,17 +1506,20 @@ export default class ActivityService extends LoggerBase {
15061506
) as boolean
15071507

15081508
if (!isBot) {
1509-
const emailDomain = payload.activity.member.identities
1510-
?.filter((i) => i.type === MemberIdentityType.EMAIL && i.verified)
1511-
.map((i) => i.value.split('@')[1]?.toLowerCase())
1512-
.find((domain) => domain && !isDomainExcluded(domain))
1509+
// Trust the email the activity arrived with (username only).
1510+
// Public inbox domains (gmail, etc.) don't identify an org, so they're skipped.
1511+
const domain = isValidEmail(payload.activity.username)
1512+
? payload.activity.username.split('@')[1]?.toLowerCase()
1513+
: undefined
1514+
1515+
const affiliationEmailDomain = domain && !isDomainExcluded(domain) ? domain : undefined
15131516

15141517
// associate activity with organization
15151518
payload.organizationId = await this.commonMemberService.findAffiliation(
15161519
payload.memberId,
15171520
payload.segmentId,
15181521
payload.activity.timestamp,
1519-
emailDomain,
1522+
affiliationEmailDomain,
15201523
)
15211524
} else {
15221525
// for bot members, we don't want to affiliate the activity with an organization

services/libs/common_services/src/services/common.member.service.ts

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
deleteMemberOrganizations,
2828
fetchManyMemberOrgsWithOrgData,
2929
fetchManyOrganizationAffiliationPolicies,
30+
fetchManyOrganizationVerifiedPrimaryDomains,
3031
fetchMemberOrganizations,
3132
findAllUnkownDatedOrganizations,
3233
findIdentitiesForMembers,
@@ -40,6 +41,7 @@ import {
4041
moveAffiliationsBetweenMembers,
4142
moveIdentitiesBetweenMembers,
4243
moveOrgsBetweenMembers,
44+
preferCompanyOverUniversityWhenOverlapping,
4345
updateMember,
4446
} from '@crowd/data-access-layer'
4547
import { removeMemberToMerge } from '@crowd/data-access-layer/src/member_merge'
@@ -177,8 +179,9 @@ export class CommonMemberService extends LoggerBase {
177179
memberId: string,
178180
segmentId: string,
179181
timestamp: string,
180-
emailDomain?: string,
182+
affiliationEmailDomain?: string,
181183
): Promise<string | null> {
184+
// 1. Manual Segment Affiliations always take absolute priority
182185
const manualAffiliation = await findMemberManualAffiliation(
183186
this.qx,
184187
memberId,
@@ -189,16 +192,42 @@ export class CommonMemberService extends LoggerBase {
189192
return manualAffiliation.organizationId
190193
}
191194

192-
const currentEmployments = await findMemberWorkExperience(
193-
this.qx,
194-
memberId,
195-
timestamp,
196-
emailDomain,
197-
)
195+
// 2. When the activity carries an org email domain, match member orgs by verified primary domain
196+
if (affiliationEmailDomain) {
197+
const domainEmployments = await findMemberWorkExperience(
198+
this.qx,
199+
memberId,
200+
timestamp,
201+
affiliationEmailDomain,
202+
)
203+
204+
if (domainEmployments.length > 0) {
205+
return this.decidePrimaryOrganizationId(domainEmployments)
206+
}
207+
}
208+
209+
// 3. Date matching: work history active at this timestamp
210+
const currentEmployments = await findMemberWorkExperience(this.qx, memberId, timestamp)
198211
if (currentEmployments.length > 0) {
199-
return this.decidePrimaryOrganizationId(currentEmployments)
212+
let employments = currentEmployments
213+
214+
if (employments.length > 1) {
215+
const organizationIds = [...new Set(employments.map((row) => row.organizationId))]
216+
217+
const memberOrgDomains = await fetchManyOrganizationVerifiedPrimaryDomains(
218+
this.qx,
219+
organizationIds,
220+
)
221+
222+
// Also applies when step 2 found a domain but no matching member organization yet
223+
// (e.g. ingest before stint inference).
224+
employments = preferCompanyOverUniversityWhenOverlapping(employments, memberOrgDomains)
225+
}
226+
227+
return this.decidePrimaryOrganizationId(employments)
200228
}
201229

230+
// 4. Fallback: Most recent experiences with missing/unknown dates
202231
const mostRecentUnknownDatedOrgs = await findMostRecentUnknownDatedOrganizations(
203232
this.qx,
204233
memberId,
@@ -208,6 +237,7 @@ export class CommonMemberService extends LoggerBase {
208237
return this.decidePrimaryOrganizationId(mostRecentUnknownDatedOrgs)
209238
}
210239

240+
// 5. Last Resort: Any historical undated organization tied to the member
211241
const allUnkownDAtedOrgs = await findAllUnkownDatedOrganizations(this.qx, memberId)
212242
if (allUnkownDAtedOrgs.length > 0) {
213243
return this.decidePrimaryOrganizationId(allUnkownDAtedOrgs)

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

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ import {
1111

1212
import { findMemberAffiliations } from '../member_segment_affiliations'
1313
import { IManualAffiliationData } from '../old/apps/data_sink_worker/repo/memberAffiliation.data'
14+
import {
15+
type OrganizationVerifiedPrimaryDomains,
16+
fetchManyOrganizationVerifiedPrimaryDomains,
17+
preferCompanyOverUniversityWhenOverlapping,
18+
} from '../organizations/identities'
1419
import { QueryExecutor } from '../queryExecutor'
1520

1621
import type { MemberOrganizationWithOverrides, TimelineItem } from './types'
@@ -30,6 +35,8 @@ async function prepareMemberOrganizationAffiliationTimeline(
3035
qx: QueryExecutor,
3136
memberId: string,
3237
): Promise<TimelineItem[]> {
38+
let memberOrgDomains: OrganizationVerifiedPrimaryDomains[] = []
39+
3340
const isDateInInterval = (date: Date, start: Date | null, end: Date | null) => {
3441
return (!start || date >= start) && (!end || date <= end)
3542
}
@@ -67,6 +74,20 @@ async function prepareMemberOrganizationAffiliationTimeline(
6774
return getLongestDateRange(manualAffiliations)
6875
}
6976

77+
const memberOrgs = orgs.filter(isMemberOrganizationWithOverrides)
78+
const companyPreferredOrgs = preferCompanyOverUniversityWhenOverlapping(
79+
memberOrgs,
80+
memberOrgDomains,
81+
)
82+
83+
if (companyPreferredOrgs.length < memberOrgs.length) {
84+
const companyPreferredOrgIds = new Set(companyPreferredOrgs.map((row) => row.organizationId))
85+
orgs = orgs.filter(
86+
(row) =>
87+
!isMemberOrganizationWithOverrides(row) || companyPreferredOrgIds.has(row.organizationId),
88+
)
89+
}
90+
7091
// first check if there's a primary work experience
7192
const primaryOrgs = orgs.filter((row) => row.isPrimaryWorkExperience)
7293
if (primaryOrgs.length > 0) {
@@ -308,23 +329,67 @@ async function prepareMemberOrganizationAffiliationTimeline(
308329
.value() ?? null
309330
}
310331

311-
// We separate global and manual timelines to prevent 'stale' organizationIds
312-
// Member organizations apply globally, while member segment affiliations only override specific segments.
332+
const organizationIds = Array.from(
333+
new Set(memberOrganizations.map((row: MemberOrganizationWithOverrides) => row.organizationId)),
334+
)
335+
336+
memberOrgDomains = await fetchManyOrganizationVerifiedPrimaryDomains(
337+
qx,
338+
organizationIds as string[],
339+
)
340+
341+
// Route activities exclusively to ONE timeline pass to prevent double-processing:
342+
// 1. If an activity has a verified email domain, it lands in the email timeline.
343+
// 2. Otherwise, fallback to the date-based timeline (excluding these known domains).
344+
const emailDomains = [...new Set(memberOrgDomains.flatMap((row) => row.domains))]
345+
const nonEmailActivityFilter =
346+
emailDomains.length > 0 ? { excludeEmailDomains: emailDomains } : {}
347+
348+
// Separate global and manual timelines to prevent stale data.
349+
// Global member orgs apply everywhere; manual segment affiliations act as localized overrides.
313350
const baseTimeline = buildTimeline(memberOrganizations, fallbackOrganizationId).map((item) => ({
314351
...item,
352+
...nonEmailActivityFilter,
315353
skipManualAffiliationSegments: manualAffiliations.length > 0,
316354
}))
317355

318-
// Only keep items with an actual org. Gaps (null org) are already handled by the base timeline.
356+
// Activities on a member-org email domain belong to that org, overriding whatever
357+
// role the date-based timeline would have picked during an overlap.
358+
const domainsByOrgId = _.keyBy(memberOrgDomains, 'orgId')
359+
const memberOrgsPerDomain = _.flatMap(memberOrganizations, (memberOrganization) =>
360+
(domainsByOrgId[memberOrganization.organizationId]?.domains ?? []).map((domain) => ({
361+
memberOrganization,
362+
domain,
363+
})),
364+
)
365+
366+
const emailAffiliations = _.flatMap(
367+
_.groupBy(memberOrgsPerDomain, 'domain'),
368+
(entries, matchEmailDomain) => {
369+
const primary = selectPrimaryWorkExperience(entries.map((entry) => entry.memberOrganization))
370+
371+
return [
372+
{
373+
organizationId: primary.organizationId,
374+
dateStart: new Date('1970-01-01').toISOString(),
375+
dateEnd: null,
376+
matchEmailDomain,
377+
skipManualAffiliationSegments: manualAffiliations.length > 0,
378+
},
379+
]
380+
},
381+
)
382+
383+
// Only keep items with a valid org; gaps (null orgs) are already handled by the base timeline.
319384
const manualTimeline = _.flatMap(
320385
_.groupBy(manualAffiliations, 'segmentId'),
321386
(affiliations, segmentId) => {
322387
const items = buildTimeline(affiliations, null, false)
323388
.filter((item) => item.organizationId !== null)
324389
.map((item) => ({ ...item, segmentId }))
325390

326-
// Undated MSAs are invisible to buildTimeline (no dateStart to anchor the loop).
327-
// Create a catch-all so the base pass's NOT EXISTS still has a matching manual item.
391+
// Manual affiliations without dates are ignored by buildTimeline (no anchor point).
392+
// Create a 1970 catch-all so the base pass's SQL `NOT EXISTS` check still matches them.
328393
if (items.length === 0) {
329394
const primary = selectPrimaryWorkExperience(affiliations)
330395
items.push({
@@ -339,7 +404,7 @@ async function prepareMemberOrganizationAffiliationTimeline(
339404
},
340405
)
341406

342-
return [...baseTimeline, ...manualTimeline]
407+
return [...baseTimeline, ...manualTimeline, ...emailAffiliations]
343408
}
344409

345410
async function processAffiliationActivities(
@@ -377,6 +442,19 @@ async function processAffiliationActivities(
377442
params.dateEnd = affiliation.dateEnd
378443
}
379444

445+
// Give each pass a disjoint slice of activities by email so no row is written twice: matchEmailDomain
446+
// takes activities on this org's domain, excludeEmailDomains takes the rest (no '@', or a foreign domain).
447+
if (affiliation.matchEmailDomain) {
448+
conditions.push(`split_part(lower(ar.username), '@', 2) = $(matchEmailDomain)`)
449+
params.matchEmailDomain = affiliation.matchEmailDomain
450+
} else if (affiliation.excludeEmailDomains?.length) {
451+
conditions.push(`(
452+
position('@' in coalesce(ar.username, '')) = 0
453+
OR lower(split_part(ar.username, '@', 2)) NOT IN ($(excludeEmailDomains:csv))
454+
)`)
455+
params.excludeEmailDomains = affiliation.excludeEmailDomains
456+
}
457+
380458
// Segment filtering (for manual affiliations)
381459
if (affiliation.segmentId) {
382460
conditions.push(`ar."segmentId" = $(segmentId)`)

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,15 @@ export type TimelineItem = {
1111
organizationId: string | null
1212
segmentId?: string
1313
skipManualAffiliationSegments?: boolean
14+
15+
/**
16+
* Routes activities by their email domain so timeline passes never overwrite each other.
17+
* Claims activities that match this specific organization domain.
18+
*/
19+
matchEmailDomain?: string
20+
21+
/**
22+
* Excludes activities belonging to these specific email domains from being claimed.
23+
*/
24+
excludeEmailDomains?: string[]
1425
}

services/libs/data-access-layer/src/members/segments.ts

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -207,42 +207,47 @@ export async function findMemberWorkExperience(
207207
qx: QueryExecutor,
208208
memberId: string,
209209
timestamp: string,
210-
emailDomain?: string,
210+
orgDomain?: string,
211211
): Promise<IWorkExperienceData[] | null> {
212-
let domainOrClause = ''
213-
if (emailDomain) {
214-
domainOrClause = `
215-
OR (mo."source" = 'email-domain' AND EXISTS (
216-
SELECT 1 FROM "organizationIdentities" oi
217-
WHERE oi."organizationId" = mo."organizationId"
218-
AND oi.type = 'primary-domain'
219-
AND oi.verified = true
220-
AND LOWER(oi.value) = LOWER($(emailDomain))
221-
))
222-
`
223-
}
212+
// Base date filter used across all timeline queries
213+
const dateCriteria = `
214+
(mo."dateStart" <= $(timestamp) AND (mo."dateEnd" >= $(timestamp) OR mo."dateEnd" IS NULL))
215+
`
216+
217+
// When an activity has an email domain, strictly force a match against verified org domains.
218+
const activeAtTimestampClause = orgDomain
219+
? `
220+
AND EXISTS (
221+
SELECT 1
222+
FROM "organizationIdentities" oi
223+
WHERE oi."organizationId" = mo."organizationId"
224+
AND oi.type = 'primary-domain'
225+
AND oi.verified = true
226+
AND lower(oi.value) = lower($(orgDomain))
227+
)
228+
`
229+
: `
230+
AND ${dateCriteria}
231+
`
224232

225233
const result = await qx.select(
226234
`
227235
SELECT
228-
mo.*,
229-
coalesce(ovr."isPrimaryWorkExperience", false) as "isPrimaryWorkExperience"
236+
mo.*,
237+
coalesce(ovr."isPrimaryWorkExperience", false) AS "isPrimaryWorkExperience"
230238
FROM "memberOrganizations" mo
231-
LEFT JOIN "memberOrganizationAffiliationOverrides" ovr on ovr."memberOrganizationId" = mo."id"
239+
LEFT JOIN "memberOrganizationAffiliationOverrides" ovr
240+
ON ovr."memberOrganizationId" = mo.id
232241
WHERE mo."memberId" = $(memberId)
233242
AND mo."deletedAt" IS NULL
234243
AND coalesce(ovr."allowAffiliation", true) = true
235-
AND (
236-
(mo."dateStart" <= $(timestamp) AND mo."dateEnd" >= $(timestamp))
237-
OR (mo."dateStart" <= $(timestamp) AND mo."dateEnd" IS NULL)
238-
${domainOrClause}
239-
)
244+
${activeAtTimestampClause}
240245
ORDER BY mo."dateStart" DESC, mo.id
241246
`,
242247
{
243248
memberId,
244249
timestamp,
245-
emailDomain,
250+
orgDomain,
246251
},
247252
)
248253

0 commit comments

Comments
 (0)