@@ -26,6 +26,9 @@ import {
2626} from '@crowd/types'
2727
2828import { queryActivityRelations } from '../activityRelations'
29+ import { deleteMemberSegmentAffiliations } from '../member_segment_affiliations'
30+ import { deleteMemberOrganizations } from '../members/organizations'
31+ import { insertOrganizationSegments } from '../organizations/segments'
2932
3033import {
3134 prepareMemberOrganizationAffiliationTimeline ,
@@ -231,18 +234,37 @@ describe('prepareMemberOrganizationAffiliationTimeline', () => {
231234 ] )
232235
233236 const timeline = await prepareMemberOrganizationAffiliationTimeline ( qx , member . id )
234- const orgs = baseItems ( timeline ) . map ( ( item ) => item . organizationId )
235-
236- expect ( orgs [ 0 ] ) . toBe ( undated . id )
237- expect ( orgs ) . toContain ( early . id )
238- expect ( orgs ) . toContain ( later . id )
239- // Mid-career hole between early end and later start must also be undated.
240- const gapIdx = orgs . findIndex ( ( id , i ) => id === undated . id && i > 0 && orgs [ i - 1 ] === early . id )
241- expect ( gapIdx ) . toBeGreaterThan ( 0 )
242- expect ( orgs [ gapIdx + 1 ] ) . toBe ( later . id )
237+
238+ expectItems ( baseItems ( timeline ) , [
239+ {
240+ organizationId : undated . id ,
241+ dateStart : EPOCH ,
242+ dateEnd : '2017-06-30T00:00:00.000Z' ,
243+ } ,
244+ {
245+ organizationId : early . id ,
246+ dateStart : '2017-07-01T00:00:00.000Z' ,
247+ dateEnd : '2020-01-01T00:00:00.000Z' ,
248+ } ,
249+ {
250+ organizationId : undated . id ,
251+ dateStart : '2020-01-02T00:00:00.000Z' ,
252+ dateEnd : '2020-02-29T00:00:00.000Z' ,
253+ } ,
254+ {
255+ organizationId : later . id ,
256+ dateStart : '2020-03-01T00:00:00.000Z' ,
257+ dateEnd : '2021-08-01T00:00:00.000Z' ,
258+ } ,
259+ {
260+ organizationId : undated . id ,
261+ dateStart : '2021-08-02T00:00:00.000Z' ,
262+ dateEnd : null ,
263+ } ,
264+ ] )
243265 } )
244266
245- test ( 'primary open-ended employer wins over overlapping blocked stints ' , async ( { qx } ) => {
267+ test ( 'allowAffiliation false drops overlapping stints from the timeline ' , async ( { qx } ) => {
246268 const [ employer , internship , foundation , undated ] = await createOrganizations (
247269 qx ,
248270 withOrganizationDefaults ( [
@@ -290,12 +312,6 @@ describe('prepareMemberOrganizationAffiliationTimeline', () => {
290312 ] )
291313
292314 await createMemberOrganizationAffiliationOverrides ( qx , [
293- {
294- memberId : member . id ,
295- memberOrganizationId : mos [ 0 ] . id ,
296- allowAffiliation : true ,
297- isPrimaryWorkExperience : true ,
298- } ,
299315 {
300316 memberId : member . id ,
301317 memberOrganizationId : mos [ 1 ] . id ,
@@ -390,12 +406,15 @@ describe('prepareMemberOrganizationAffiliationTimeline', () => {
390406 expect ( baseItems ( timeline ) . every ( ( item ) => item . organizationId !== orgA . id ) ) . toBe ( true )
391407 } )
392408
393- test ( 'blacklisted titles like Investor and Mentor are excluded' , async ( { qx } ) => {
394- const [ investorOrg , mentorOrg , engOrg ] = await createOrganizations (
409+ test ( 'blacklisted titles like Investor, Mentor, and Board Member are excluded' , async ( {
410+ qx,
411+ } ) => {
412+ const [ investorOrg , mentorOrg , boardOrg , engOrg ] = await createOrganizations (
395413 qx ,
396414 withOrganizationDefaults ( [
397415 { displayName : 'Various Startups' } ,
398416 { displayName : 'Google' } ,
417+ { displayName : 'Acme Board' } ,
399418 { displayName : 'Red Hat' } ,
400419 ] ) ,
401420 )
@@ -418,6 +437,14 @@ describe('prepareMemberOrganizationAffiliationTimeline', () => {
418437 title : 'Mentor' ,
419438 source : OrganizationSource . ENRICHMENT_CRUSTDATA ,
420439 } ,
440+ {
441+ memberId : member . id ,
442+ organizationId : boardOrg . id ,
443+ dateStart : '2018-01-01T00:00:00.000Z' ,
444+ dateEnd : '2020-01-01T00:00:00.000Z' ,
445+ title : 'Board Member' ,
446+ source : OrganizationSource . ENRICHMENT_CRUSTDATA ,
447+ } ,
421448 {
422449 memberId : member . id ,
423450 organizationId : engOrg . id ,
@@ -433,9 +460,81 @@ describe('prepareMemberOrganizationAffiliationTimeline', () => {
433460
434461 expect ( orgIds ) . not . toContain ( investorOrg . id )
435462 expect ( orgIds ) . not . toContain ( mentorOrg . id )
463+ expect ( orgIds ) . not . toContain ( boardOrg . id )
436464 expect ( orgIds ) . toContain ( engOrg . id )
437465 } )
438466
467+ test ( 'soft-deleted MO is ignored' , async ( { qx } ) => {
468+ const [ activeOrg , deletedOrg ] = await createOrganizations (
469+ qx ,
470+ withOrganizationDefaults ( [ { displayName : 'Red Hat' } , { displayName : 'Gone Co' } ] ) ,
471+ )
472+ const [ member ] = await createMembers ( qx , withMemberDefaults ( [ { } ] ) )
473+
474+ const [ , deletedMo ] = await createMemberOrganizations ( qx , [
475+ {
476+ memberId : member . id ,
477+ organizationId : activeOrg . id ,
478+ dateStart : '2022-01-01T00:00:00.000Z' ,
479+ dateEnd : null ,
480+ source : OrganizationSource . UI ,
481+ } ,
482+ {
483+ memberId : member . id ,
484+ organizationId : deletedOrg . id ,
485+ dateStart : '2020-01-01T00:00:00.000Z' ,
486+ dateEnd : '2021-01-01T00:00:00.000Z' ,
487+ source : OrganizationSource . UI ,
488+ } ,
489+ ] )
490+
491+ await deleteMemberOrganizations ( qx , member . id , [ deletedMo . id ] )
492+
493+ const timeline = await prepareMemberOrganizationAffiliationTimeline ( qx , member . id )
494+ const orgIds = baseItems ( timeline ) . map ( ( item ) => item . organizationId )
495+
496+ expect ( orgIds ) . toContain ( activeOrg . id )
497+ expect ( orgIds ) . not . toContain ( deletedOrg . id )
498+ } )
499+
500+ test ( 'soft-deleted MSA is ignored' , async ( { qx } ) => {
501+ const [ employer , sponsor ] = await createOrganizations (
502+ qx ,
503+ withOrganizationDefaults ( [ { displayName : 'Akuity Inc' } , { displayName : 'Red Hat' } ] ) ,
504+ )
505+ const [ member ] = await createMembers ( qx , withMemberDefaults ( [ { } ] ) )
506+ const { kubernetes } = await createLeafSegments ( qx )
507+
508+ await createMemberOrganizations ( qx , [
509+ {
510+ memberId : member . id ,
511+ organizationId : employer . id ,
512+ dateStart : '2021-08-01T00:00:00.000Z' ,
513+ dateEnd : null ,
514+ source : OrganizationSource . UI ,
515+ } ,
516+ ] )
517+
518+ const [ msa ] = await createMemberSegmentAffiliations ( qx , [
519+ {
520+ memberId : member . id ,
521+ segmentId : kubernetes . id ,
522+ organizationId : sponsor . id ,
523+ dateStart : null ,
524+ dateEnd : null ,
525+ } ,
526+ ] )
527+
528+ await deleteMemberSegmentAffiliations ( qx , { ids : [ msa . id ] } )
529+
530+ const timeline = await prepareMemberOrganizationAffiliationTimeline ( qx , member . id )
531+
532+ expect ( manualItems ( timeline ) ) . toHaveLength ( 0 )
533+ expect ( baseItems ( timeline ) . every ( ( item ) => item . skipManualAffiliationSegments === false ) ) . toBe (
534+ true ,
535+ )
536+ } )
537+
439538 test ( 'undated MSA is a 1970 catch-all; base timeline skips that segment' , async ( { qx } ) => {
440539 const [ employer , sponsor ] = await createOrganizations (
441540 qx ,
@@ -475,6 +574,28 @@ describe('prepareMemberOrganizationAffiliationTimeline', () => {
475574 )
476575 } )
477576
577+ test ( 'without MSA, base timeline does not skip manual affiliation segments' , async ( { qx } ) => {
578+ const [ org ] = await createOrganizations ( qx , withOrganizationDefaults ( [ { displayName : 'Meta' } ] ) )
579+ const [ member ] = await createMembers ( qx , withMemberDefaults ( [ { } ] ) )
580+
581+ await createMemberOrganizations ( qx , [
582+ {
583+ memberId : member . id ,
584+ organizationId : org . id ,
585+ dateStart : '2019-06-01T00:00:00.000Z' ,
586+ dateEnd : null ,
587+ source : OrganizationSource . UI ,
588+ } ,
589+ ] )
590+
591+ const timeline = await prepareMemberOrganizationAffiliationTimeline ( qx , member . id )
592+
593+ expect ( manualItems ( timeline ) ) . toHaveLength ( 0 )
594+ expect ( baseItems ( timeline ) . every ( ( item ) => item . skipManualAffiliationSegments === false ) ) . toBe (
595+ true ,
596+ )
597+ } )
598+
478599 test ( 'dated MSA keeps its date range on the segment timeline' , async ( { qx } ) => {
479600 const [ org ] = await createOrganizations (
480601 qx ,
@@ -584,6 +705,110 @@ describe('prepareMemberOrganizationAffiliationTimeline', () => {
584705 expect ( duringOverlap ) . toBeDefined ( )
585706 } )
586707
708+ test ( 'longer stint wins when overlapping dated MOs share source and memberCount' , async ( {
709+ qx,
710+ } ) => {
711+ const [ longer , shorter ] = await createOrganizations (
712+ qx ,
713+ withOrganizationDefaults ( [ { displayName : 'Long Co' } , { displayName : 'Short Co' } ] ) ,
714+ )
715+ const [ member ] = await createMembers ( qx , withMemberDefaults ( [ { } ] ) )
716+
717+ await createMemberOrganizations ( qx , [
718+ {
719+ memberId : member . id ,
720+ organizationId : longer . id ,
721+ dateStart : '2020-01-01T00:00:00.000Z' ,
722+ dateEnd : '2023-01-01T00:00:00.000Z' ,
723+ source : OrganizationSource . ENRICHMENT_CRUSTDATA ,
724+ } ,
725+ {
726+ memberId : member . id ,
727+ organizationId : shorter . id ,
728+ dateStart : '2021-01-01T00:00:00.000Z' ,
729+ dateEnd : '2022-01-01T00:00:00.000Z' ,
730+ source : OrganizationSource . ENRICHMENT_CRUSTDATA ,
731+ } ,
732+ ] )
733+
734+ const timeline = await prepareMemberOrganizationAffiliationTimeline ( qx , member . id )
735+ const duringOverlap = baseItems ( timeline ) . find (
736+ ( item ) => item . organizationId === longer . id && item . dateStart === '2020-01-01T00:00:00.000Z' ,
737+ )
738+
739+ expect ( duringOverlap ) . toBeDefined ( )
740+ expect (
741+ baseItems ( timeline ) . some (
742+ ( item ) =>
743+ item . organizationId === shorter . id && item . dateStart === '2021-01-01T00:00:00.000Z' ,
744+ ) ,
745+ ) . toBe ( false )
746+ } )
747+
748+ test ( 'higher memberCount wins when overlapping dated MOs share source' , async ( { qx } ) => {
749+ const [ popular , niche ] = await createOrganizations (
750+ qx ,
751+ withOrganizationDefaults ( [ { displayName : 'Popular Co' } , { displayName : 'Niche Co' } ] ) ,
752+ )
753+ const [ member ] = await createMembers ( qx , withMemberDefaults ( [ { } ] ) )
754+ const { kubernetes } = await createLeafSegments ( qx )
755+
756+ // Niche stint is longer; popular must win via memberCount, not longest-range.
757+ await createMemberOrganizations ( qx , [
758+ {
759+ memberId : member . id ,
760+ organizationId : popular . id ,
761+ dateStart : '2021-01-01T00:00:00.000Z' ,
762+ dateEnd : '2022-01-01T00:00:00.000Z' ,
763+ source : OrganizationSource . ENRICHMENT_CRUSTDATA ,
764+ } ,
765+ {
766+ memberId : member . id ,
767+ organizationId : niche . id ,
768+ dateStart : '2020-01-01T00:00:00.000Z' ,
769+ dateEnd : '2023-01-01T00:00:00.000Z' ,
770+ source : OrganizationSource . ENRICHMENT_CRUSTDATA ,
771+ } ,
772+ ] )
773+
774+ await insertOrganizationSegments ( qx , [
775+ {
776+ organizationId : popular . id ,
777+ segmentId : kubernetes . id ,
778+ memberCount : 10_000 ,
779+ activityCount : 1 ,
780+ activeOn : [ 'github' ] ,
781+ joinedAt : '2020-01-01T00:00:00.000Z' ,
782+ lastActive : '2024-01-01T00:00:00.000Z' ,
783+ avgContributorEngagement : 0 ,
784+ } ,
785+ {
786+ organizationId : niche . id ,
787+ segmentId : kubernetes . id ,
788+ memberCount : 10 ,
789+ activityCount : 1 ,
790+ activeOn : [ 'github' ] ,
791+ joinedAt : '2020-01-01T00:00:00.000Z' ,
792+ lastActive : '2024-01-01T00:00:00.000Z' ,
793+ avgContributorEngagement : 0 ,
794+ } ,
795+ ] )
796+
797+ const timeline = await prepareMemberOrganizationAffiliationTimeline ( qx , member . id )
798+
799+ expect (
800+ baseItems ( timeline ) . some (
801+ ( item ) =>
802+ item . organizationId === popular . id && item . dateStart === '2021-01-01T00:00:00.000Z' ,
803+ ) ,
804+ ) . toBe ( true )
805+ expect (
806+ baseItems ( timeline ) . some (
807+ ( item ) => item . organizationId === niche . id && item . dateStart === '2021-01-01T00:00:00.000Z' ,
808+ ) ,
809+ ) . toBe ( false )
810+ } )
811+
587812 test ( 'company is preferred over university when dated stints overlap' , async ( { qx } ) => {
588813 const [ company , university ] = await createOrganizations (
589814 qx ,
@@ -823,6 +1048,57 @@ describe('refreshMemberOrganizationAffiliations', () => {
8231048 expect ( await countByOrg ( qx , member . id , kubernetes . id , employer . id ) ) . toBe ( 0 )
8241049 } )
8251050
1051+ test ( 'dated MSA applies only inside its window; MO covers activities outside' , async ( { qx } ) => {
1052+ const [ employer , sponsor ] = await createOrganizations (
1053+ qx ,
1054+ withOrganizationDefaults ( [ { displayName : 'Akuity Inc' } , { displayName : 'Red Hat' } ] ) ,
1055+ )
1056+ const { member, identities } = await createMemberWithIdentity ( qx , { value : 'contributor' } )
1057+ const { kubernetes } = await createLeafSegments ( qx )
1058+
1059+ await createMemberOrganizations ( qx , [
1060+ {
1061+ memberId : member . id ,
1062+ organizationId : employer . id ,
1063+ dateStart : '2020-01-01T00:00:00.000Z' ,
1064+ dateEnd : null ,
1065+ source : OrganizationSource . UI ,
1066+ } ,
1067+ ] )
1068+
1069+ await createMemberSegmentAffiliations ( qx , [
1070+ {
1071+ memberId : member . id ,
1072+ segmentId : kubernetes . id ,
1073+ organizationId : sponsor . id ,
1074+ dateStart : '2021-01-01T00:00:00.000Z' ,
1075+ dateEnd : '2021-06-01T00:00:00.000Z' ,
1076+ } ,
1077+ ] )
1078+
1079+ await createActivityRelations ( qx , [
1080+ ...generateActivityRelations ( {
1081+ memberId : member . id ,
1082+ segmentId : kubernetes . id ,
1083+ identities,
1084+ count : 40 ,
1085+ timestamp : { from : '2021-02-01T00:00:00.000Z' , to : '2021-05-01T00:00:00.000Z' } ,
1086+ } ) ,
1087+ ...generateActivityRelations ( {
1088+ memberId : member . id ,
1089+ segmentId : kubernetes . id ,
1090+ identities,
1091+ count : 30 ,
1092+ timestamp : { from : '2022-01-01T00:00:00.000Z' , to : '2022-06-01T00:00:00.000Z' } ,
1093+ } ) ,
1094+ ] )
1095+
1096+ await refreshMemberOrganizationAffiliations ( qx , member . id )
1097+
1098+ expect ( await countByOrg ( qx , member . id , kubernetes . id , sponsor . id ) ) . toBe ( 40 )
1099+ expect ( await countByOrg ( qx , member . id , kubernetes . id , employer . id ) ) . toBe ( 30 )
1100+ } )
1101+
8261102 test ( 'email-domain activities route to matching org over the date timeline' , async ( { qx } ) => {
8271103 const [ emailOrg , datedOrg ] = await createOrganizations (
8281104 qx ,
0 commit comments