@@ -3,7 +3,6 @@ import axios from 'axios'
33import _ from 'lodash'
44
55import {
6- generateUUIDv1 ,
76 getAttributeValue ,
87 getCountry ,
98 hasAttributeValue ,
@@ -60,7 +59,6 @@ import {
6059 OrganizationAttributeSource ,
6160 OrganizationIdentityType ,
6261 OrganizationMergeSuggestionTable ,
63- OrganizationSource ,
6462 PlatformType ,
6563} from '@crowd/types'
6664
@@ -74,6 +72,11 @@ import {
7472 IMemberEnrichmentDataNormalizedOrganization ,
7573} from '../types'
7674
75+ import {
76+ hasMemberOrganizationTimelineChange ,
77+ prepareWorkExperiences ,
78+ } from './workExperienceReconciliation'
79+
7780/* eslint-disable @typescript-eslint/no-explicit-any */
7881
7982// Get the most strict parallelism among existing and enrichable sources
@@ -244,7 +247,7 @@ export async function getPriorityArray(): Promise<string[]> {
244247
245248export async function fetchMemberDataForLLMSquashing (
246249 memberId : string ,
247- ) : Promise < IMemberOriginalData > {
250+ ) : Promise < IMemberOriginalData | null > {
248251 return fetchMemberDataForLLMSquashingDb ( svc . postgres . reader . connection ( ) , memberId )
249252}
250253
@@ -601,12 +604,16 @@ export async function updateMemberUsingSquashedPayload(
601604 existingMemberData . organizations ,
602605 squashedPayload . memberOrganizations ,
603606 isHighConfidenceSourceSelectedForWorkExperiences ,
607+ new Set ( ( existingMemberData . deletedOrganizations ?? [ ] ) . map ( ( o ) => o . orgId ) ) ,
604608 )
605609
606- // Enrichment often deletes and recreates the same orgs with identical dates.
607- // Skip the refresh when the timeline that drives activityRelations hasn't changed.
610+ // Skip the refresh when the timeline that drives activityRelations hasn't changed —
611+ // e.g. a title-only update-in-place shouldn't trigger a full recompute.
612+ const toUpdateHasTimelineChange = Array . from ( results . toUpdate . values ( ) ) . some (
613+ ( fields ) => 'dateStart' in fields || 'dateEnd' in fields ,
614+ )
608615 affiliationNeedsRefresh =
609- results . toUpdate . size > 0 ||
616+ toUpdateHasTimelineChange ||
610617 hasMemberOrganizationTimelineChange ( results . toDelete , results . toCreate )
611618
612619 if ( results . toDelete . length > 0 ) {
@@ -788,12 +795,6 @@ export async function refreshMemberEnrichmentMaterializedView(mvName: string): P
788795 await refreshMaterializedView ( svc . postgres . writer . connection ( ) , mvName , true )
789796}
790797
791- interface IWorkExperienceChanges {
792- toDelete : IMemberOrganizationData [ ]
793- toCreate : IMemberEnrichmentDataNormalizedOrganization [ ]
794- toUpdate : Map < IMemberOrganizationData , Record < string , any > >
795- }
796-
797798function sanitizeWorkExperienceDateRanges (
798799 organizations : IMemberEnrichmentDataNormalizedOrganization [ ] ,
799800) : IMemberEnrichmentDataNormalizedOrganization [ ] {
@@ -808,135 +809,6 @@ function sanitizeWorkExperienceDateRanges(
808809 } )
809810}
810811
811- /**
812- * Returns true when the set of (orgId, startDate, endDate) tuples differs
813- * between deletes and creates. Fields like title or source don't affect
814- * the affiliation timeline, so they're intentionally ignored.
815- */
816- function hasMemberOrganizationTimelineChange (
817- toDelete : IMemberOrganizationData [ ] ,
818- toCreate : IMemberEnrichmentDataNormalizedOrganization [ ] ,
819- ) : boolean {
820- const toKey = ( orgId : string , start : string | null | undefined , end : string | null | undefined ) =>
821- `${ orgId } |${ start ? start . substring ( 0 , 10 ) : '' } |${ end ? end . substring ( 0 , 10 ) : '' } `
822-
823- const deletedKeys = new Set ( toDelete . map ( ( d ) => toKey ( d . orgId , d . dateStart , d . dateEnd ) ) )
824- const createdKeys = new Set ( toCreate . map ( ( c ) => toKey ( c . organizationId , c . startDate , c . endDate ) ) )
825-
826- if ( deletedKeys . size !== createdKeys . size ) return true
827- for ( const key of deletedKeys ) {
828- if ( ! createdKeys . has ( key ) ) return true
829- }
830- return false
831- }
832-
833- function prepareWorkExperiences (
834- oldVersion : IMemberOrganizationData [ ] ,
835- newVersion : IMemberEnrichmentDataNormalizedOrganization [ ] ,
836- isHighConfidenceSourceSelectedForWorkExperiences : boolean ,
837- ) : IWorkExperienceChanges {
838- // we delete all the work experiences that were not manually created or from the project registry.
839- const toDelete = oldVersion . filter (
840- ( c ) => c . source !== OrganizationSource . UI && c . source !== OrganizationSource . PROJECT_REGISTRY ,
841- )
842-
843- const toCreate : IMemberEnrichmentDataNormalizedOrganization [ ] = [ ]
844- // eslint-disable-next-line @typescript-eslint/no-explicit-any
845- const toUpdate : Map < IMemberOrganizationData , Record < string , any > > = new Map ( )
846-
847- if ( isHighConfidenceSourceSelectedForWorkExperiences ) {
848- const uiEntries = oldVersion . filter ( ( c ) => c . source === OrganizationSource . UI )
849- const filteredNewVersion = newVersion . filter (
850- ( e ) =>
851- ! uiEntries . some (
852- ( ui ) =>
853- e . title === ui . jobTitle &&
854- e . identities &&
855- e . identities . some ( ( i ) => i . organizationId === ui . orgId ) ,
856- ) ,
857- )
858- toCreate . push ( ...filteredNewVersion )
859- return {
860- toDelete,
861- toCreate,
862- toUpdate,
863- }
864- }
865-
866- // sort both versions by start date and only use manual changes from the current version
867- const orderedCurrentVersion = oldVersion
868- . filter ( ( c ) => c . source === OrganizationSource . UI )
869- . sort ( ( a , b ) => {
870- // If either value is null/undefined, move it to the beginning
871- if ( ! a . dateStart && ! b . dateStart ) return 0
872- if ( ! a . dateStart ) return - 1
873- if ( ! b . dateStart ) return 1
874-
875- // Compare dates if both values exist
876- return new Date ( a . dateStart as string ) . getTime ( ) - new Date ( b . dateStart as string ) . getTime ( )
877- } )
878-
879- let orderedNewVersion = newVersion . sort ( ( a , b ) => {
880- // If either value is null/undefined, move it to the beginning
881- if ( ! a . startDate && ! b . startDate ) return 0
882- if ( ! a . startDate ) return - 1
883- if ( ! b . startDate ) return 1
884-
885- // Compare dates if both values exist
886- return new Date ( a . startDate as string ) . getTime ( ) - new Date ( b . startDate as string ) . getTime ( )
887- } )
888-
889- // set ids and new flag to new versions just so we can easily manipulate the array later
890- for ( const exp of orderedNewVersion ) {
891- exp . id = generateUUIDv1 ( )
892- }
893-
894- // we iterate through the existing version experiences to see if update is needed
895- for ( const current of orderedCurrentVersion ) {
896- // try and find a matching experience in the new versions by title
897- const match = orderedNewVersion . find (
898- ( e ) =>
899- e . title === current . jobTitle &&
900- e . identities &&
901- e . identities . some ( ( e ) => e . organizationId === current . orgId ) ,
902- )
903-
904- // if we found a match we can check if we need something to update
905- if (
906- match &&
907- current . dateStart === match . startDate &&
908- current . dateEnd === null &&
909- match . endDate !== null
910- ) {
911- const toUpdateInner : Record < string , any > = { }
912-
913- toUpdateInner . dateEnd = match . endDate
914- toUpdate . set ( current , toUpdateInner )
915-
916- // remove the match from the new version array so we later don't process it again
917- orderedNewVersion = orderedNewVersion . filter ( ( e ) => e . id !== match . id )
918- } else if (
919- match &&
920- ( current . dateStart !== match . startDate || current . dateEnd !== null || match . endDate === null )
921- ) {
922- // there's an incoming work experiences, but it's conflicting with the existing manually updated data
923- // we shouldn't add or update anything when this happens
924- // we can only update dateEnd of existing manually changed data, when it has a null dateEnd
925- orderedNewVersion = orderedNewVersion . filter ( ( e ) => e . id !== match . id )
926- }
927- // if we didn't find a match we should just leave it as it is in the database since it was manual input
928- }
929-
930- // the remaining experiences in the new version array are just new experiences to create
931- toCreate . push ( ...orderedNewVersion )
932-
933- return {
934- toDelete,
935- toCreate,
936- toUpdate,
937- }
938- }
939-
940812export async function syncMember ( memberId : string ) : Promise < void > {
941813 const syncApi = new SearchSyncApiClient ( {
942814 baseUrl : process . env [ 'CROWD_SEARCH_SYNC_API_URL' ] ,
0 commit comments