@@ -11,6 +11,11 @@ import {
1111
1212import { findMemberAffiliations } from '../member_segment_affiliations'
1313import { IManualAffiliationData } from '../old/apps/data_sink_worker/repo/memberAffiliation.data'
14+ import {
15+ type OrganizationVerifiedPrimaryDomains ,
16+ fetchManyOrganizationVerifiedPrimaryDomains ,
17+ preferCompanyOverUniversityWhenOverlapping ,
18+ } from '../organizations/identities'
1419import { QueryExecutor } from '../queryExecutor'
1520
1621import 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
345410async 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)` )
0 commit comments