Skip to content

Commit 148a8e9

Browse files
authored
feat: filter deleted member enrchiment worker (CM-1367) (#4476)
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
1 parent 10a84f2 commit 148a8e9

10 files changed

Lines changed: 669 additions & 160 deletions

File tree

backend/src/api/public/v1/members/work-experiences/verifyMemberWorkExperience.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,9 @@ export async function verifyMemberWorkExperience(req: Request, res: Response): P
9292
await updateMemberOrganization(tx, memberId, overlappingRow.id, verifiedUpdate)
9393
}
9494
} else {
95-
// Unverifying removes the grouped work experience from both visible and hidden rows
96-
await deleteMemberOrganizations(tx, memberId, memberOrgIdsToDelete, true)
95+
// Unverifying removes the grouped work experience from both visible and hidden rows.
96+
// This is a human decision, so deletedBy is set — enrichment must never recreate it.
97+
await deleteMemberOrganizations(tx, memberId, memberOrgIdsToDelete, true, verifiedBy)
9798
}
9899
})
99100

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
alter table "memberOrganizations"
2+
add column if not exists "deletedBy" varchar(255) default null;

backend/src/services/member/memberOrganizationsService.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,13 @@ export default class MemberOrganizationsService extends LoggerBase {
363363
]
364364

365365
// Delete hidden grouped rows with the visible row so list responses stay consistent
366-
await deleteMemberOrganizations(qx, memberId, memberOrganizationIdsToDelete, true)
366+
await deleteMemberOrganizations(
367+
qx,
368+
memberId,
369+
memberOrganizationIdsToDelete,
370+
true,
371+
this.options.currentUser.id,
372+
)
367373

368374
const result = await this.list(memberId, transaction)
369375

pnpm-lock.yaml

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

services/apps/members_enrichment_worker/src/activities/enrichment.ts

Lines changed: 13 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import axios from 'axios'
33
import _ from 'lodash'
44

55
import {
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

245248
export 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-
797798
function 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-
940812
export async function syncMember(memberId: string): Promise<void> {
941813
const syncApi = new SearchSyncApiClient({
942814
baseUrl: process.env['CROWD_SEARCH_SYNC_API_URL'],

0 commit comments

Comments
 (0)