diff --git a/backend/src/api/public/v1/members/work-experiences/deleteMemberWorkExperience.ts b/backend/src/api/public/v1/members/work-experiences/deleteMemberWorkExperience.ts index 80784bff0b..c0ea12faf9 100644 --- a/backend/src/api/public/v1/members/work-experiences/deleteMemberWorkExperience.ts +++ b/backend/src/api/public/v1/members/work-experiences/deleteMemberWorkExperience.ts @@ -13,7 +13,7 @@ import { } from '@crowd/data-access-layer' import { noContent } from '@/utils/api' -import { getOverlappingEmailDomainMemberOrganizations } from '@/utils/mapper' +import { getOverlappingGroupedMemberOrganizations } from '@/utils/mapper' import { validateOrThrow } from '@/utils/validation' const paramsSchema = z.object({ @@ -39,14 +39,11 @@ export async function deleteMemberWorkExperience(req: Request, res: Response): P throw new NotFoundError('Work experience not found') } - const overlappingEmailDomainRows = getOverlappingEmailDomainMemberOrganizations( - memberOrgs, - memberOrg, - ) + const overlappingGroupedRows = getOverlappingGroupedMemberOrganizations(memberOrgs, memberOrg) const memberOrgIdsToDelete = [ workExperienceId, - ...overlappingEmailDomainRows.flatMap((row) => (row.id ? [row.id] : [])), + ...overlappingGroupedRows.flatMap((row) => (row.id ? [row.id] : [])), ] // Delete hidden grouped rows with the visible row so read responses stay consistent diff --git a/backend/src/api/public/v1/members/work-experiences/updateMemberWorkExperience.ts b/backend/src/api/public/v1/members/work-experiences/updateMemberWorkExperience.ts index b0e04ebb05..444fc75cad 100644 --- a/backend/src/api/public/v1/members/work-experiences/updateMemberWorkExperience.ts +++ b/backend/src/api/public/v1/members/work-experiences/updateMemberWorkExperience.ts @@ -17,7 +17,7 @@ import type { MemberOrganizationDateRange, MemberOrganizationUpdate } from '@cro import { ok } from '@/utils/api' import { - getOverlappingEmailDomainMemberOrganizations, + getOverlappingGroupedMemberOrganizations, groupMemberOrganizations, toMemberWorkExperience, } from '@/utils/mapper' @@ -88,7 +88,7 @@ export async function updateMemberWorkExperience(req: Request, res: Response): P const overlapBasis = { ...existing, ...update } - const overlappingEmailDomainRows = getOverlappingEmailDomainMemberOrganizations( + const overlappingGroupedRows = getOverlappingGroupedMemberOrganizations( memberOrgs, overlapBasis, ) @@ -106,8 +106,8 @@ export async function updateMemberWorkExperience(req: Request, res: Response): P groupedUpdate.verifiedBy = data.verifiedBy } - if (overlappingEmailDomainRows.length > 0 && Object.keys(groupedUpdate).length > 0) { - for (const overlappingRow of overlappingEmailDomainRows.filter( + if (overlappingGroupedRows.length > 0 && Object.keys(groupedUpdate).length > 0) { + for (const overlappingRow of overlappingGroupedRows.filter( (row): row is typeof row & { id: string } => !!row.id, )) { await updateMemberOrganization(tx, memberId, overlappingRow.id, groupedUpdate) diff --git a/backend/src/api/public/v1/members/work-experiences/verifyMemberWorkExperience.ts b/backend/src/api/public/v1/members/work-experiences/verifyMemberWorkExperience.ts index a4fb2b3a95..f55a68a7c1 100644 --- a/backend/src/api/public/v1/members/work-experiences/verifyMemberWorkExperience.ts +++ b/backend/src/api/public/v1/members/work-experiences/verifyMemberWorkExperience.ts @@ -17,7 +17,7 @@ import { IMemberOrganization, IMemberRoleWithOrganization } from '@crowd/types' import { ok } from '@/utils/api' import { - getOverlappingEmailDomainMemberOrganizations, + getOverlappingGroupedMemberOrganizations, groupMemberOrganizations, toMemberWorkExperience, } from '@/utils/mapper' @@ -52,14 +52,11 @@ export async function verifyMemberWorkExperience(req: Request, res: Response): P throw new NotFoundError('Work experience not found') } - const overlappingEmailDomainRows = getOverlappingEmailDomainMemberOrganizations( - memberOrgs, - memberOrg, - ) + const overlappingGroupedRows = getOverlappingGroupedMemberOrganizations(memberOrgs, memberOrg) const memberOrgIdsToDelete = [ workExperienceId, - ...overlappingEmailDomainRows.flatMap((row) => (row.id ? [row.id] : [])), + ...overlappingGroupedRows.flatMap((row) => (row.id ? [row.id] : [])), ] const verifiedUpdate = { verified, verifiedBy } @@ -81,7 +78,7 @@ export async function verifyMemberWorkExperience(req: Request, res: Response): P verifiedUpdate, ) - for (const overlappingRow of overlappingEmailDomainRows.filter( + for (const overlappingRow of overlappingGroupedRows.filter( (row): row is typeof row & { id: string } => !!row.id, )) { await updateMemberOrganization(tx, memberId, overlappingRow.id, verifiedUpdate) diff --git a/backend/src/services/member/memberAffiliationsService.ts b/backend/src/services/member/memberAffiliationsService.ts index df049e020b..573c93dad4 100644 --- a/backend/src/services/member/memberAffiliationsService.ts +++ b/backend/src/services/member/memberAffiliationsService.ts @@ -20,7 +20,7 @@ import { import MemberAffiliationsRepository from '@/database/repositories/member/memberAffiliationsRepository' import SequelizeRepository from '@/database/repositories/sequelizeRepository' -import { getOverlappingEmailDomainMemberOrganizations } from '@/utils/mapper' +import { getOverlappingGroupedMemberOrganizations } from '@/utils/mapper' import { IServiceOptions } from '../IServiceOptions' @@ -113,13 +113,13 @@ export default class MemberAffiliationsService extends LoggerBase { const memberOrgs = await fetchMemberOrganizations(qx, data.memberId) const memberOrg = memberOrgs.find((mo) => mo.id === data.memberOrganizationId) - const overlappingEmailDomainRows = memberOrg - ? getOverlappingEmailDomainMemberOrganizations(memberOrgs, memberOrg) + const overlappingGroupedRows = memberOrg + ? getOverlappingGroupedMemberOrganizations(memberOrgs, memberOrg) : [] const memberOrgIds = [ data.memberOrganizationId, - ...overlappingEmailDomainRows.flatMap((row) => (row.id ? [row.id] : [])), + ...overlappingGroupedRows.flatMap((row) => (row.id ? [row.id] : [])), ] // Apply the override to hidden grouped rows so the merged work experience has one decision diff --git a/backend/src/services/member/memberOrganizationsService.ts b/backend/src/services/member/memberOrganizationsService.ts index b14febe6b2..450ce65876 100644 --- a/backend/src/services/member/memberOrganizationsService.ts +++ b/backend/src/services/member/memberOrganizationsService.ts @@ -28,10 +28,7 @@ import { } from '@crowd/types' import SequelizeRepository from '@/database/repositories/sequelizeRepository' -import { - getOverlappingEmailDomainMemberOrganizations, - groupMemberOrganizations, -} from '@/utils/mapper' +import { getOverlappingGroupedMemberOrganizations, groupMemberOrganizations } from '@/utils/mapper' import { IServiceOptions } from '../IServiceOptions' @@ -106,12 +103,12 @@ export default class MemberOrganizationsService extends LoggerBase { const allOrganizations = groupedMemberOrganizations .filter((mo): mo is typeof mo & { id: string } => !!mo.id && !!orgById[mo.organizationId]) .map((mo) => { - const overlappingEmailDomainRows = getOverlappingEmailDomainMemberOrganizations( + const overlappingGroupedRows = getOverlappingGroupedMemberOrganizations( memberOrganizations, mo, ) - const relatedIds = [mo.id, ...overlappingEmailDomainRows.map((row) => row.id)] + const relatedIds = [mo.id, ...overlappingGroupedRows.map((row) => row.id)] const relatedOverrides = relatedIds.map((memberOrganizationId) => overridesByMemberOrganizationId.get(memberOrganizationId), @@ -294,7 +291,7 @@ export default class MemberOrganizationsService extends LoggerBase { const overlapBasis = { ...existing, ...update } - const overlappingEmailDomainRows = getOverlappingEmailDomainMemberOrganizations( + const overlappingGroupedRows = getOverlappingGroupedMemberOrganizations( memberOrganizations, overlapBasis, ) @@ -309,8 +306,8 @@ export default class MemberOrganizationsService extends LoggerBase { (value) => value !== undefined, ) as MemberOrganizationUpdate - if (overlappingEmailDomainRows.length > 0 && Object.keys(groupedUpdate).length > 0) { - for (const overlappingRow of overlappingEmailDomainRows) { + if (overlappingGroupedRows.length > 0 && Object.keys(groupedUpdate).length > 0) { + for (const overlappingRow of overlappingGroupedRows) { if (!overlappingRow.id) { continue } @@ -355,14 +352,14 @@ export default class MemberOrganizationsService extends LoggerBase { throw new Error404(`Member organization with id ${id} not found!`) } - const overlappingEmailDomainRows = getOverlappingEmailDomainMemberOrganizations( + const overlappingGroupedRows = getOverlappingGroupedMemberOrganizations( existingMemberOrganizations, memberOrganizationToBeDeleted, ) const memberOrganizationIdsToDelete = [ id, - ...overlappingEmailDomainRows.flatMap((row) => (row.id ? [row.id] : [])), + ...overlappingGroupedRows.flatMap((row) => (row.id ? [row.id] : [])), ] // Delete hidden grouped rows with the visible row so list responses stay consistent diff --git a/backend/src/utils/mapper.ts b/backend/src/utils/mapper.ts index 44369f6c15..02e072f4e2 100644 --- a/backend/src/utils/mapper.ts +++ b/backend/src/utils/mapper.ts @@ -15,58 +15,107 @@ function memberOrganizationsOverlap(a: T, b: T): ) } -/** - * Finds email-domain rows that are represented by the same visible work experience. - */ -export function getOverlappingEmailDomainMemberOrganizations( +function isCollapsibleMemberOrganization(row: T): boolean { + if (!row.source) { + return false + } + + return row.source + .split(',') + .map((value) => value.trim()) + .some((value) => + [OrganizationSource.EMAIL_DOMAIN, OrganizationSource.PROJECT_REGISTRY].includes( + value as OrganizationSource, + ), + ) +} + +function compareMemberOrganizationsBySourceRank( + a: IMemberOrganization, + b: IMemberOrganization, +): number { + const rankDiff = + getMemberOrganizationSourceRank(a.source) - getMemberOrganizationSourceRank(b.source) + if (rankDiff !== 0) { + return rankDiff + } + + return (a.id ?? '').localeCompare(b.id ?? '') +} + +/** Hidden inferential rows that overlap a visible work experience (for delete/update/override). */ +export function getOverlappingGroupedMemberOrganizations( rows: T[], memberOrganization: T, ): T[] { return rows.filter( (row) => row.id !== memberOrganization.id && - row.source === OrganizationSource.EMAIL_DOMAIN && + isCollapsibleMemberOrganization(row) && memberOrganizationsOverlap(row, memberOrganization), ) } -/** - * Groups overlapping email-domain rows into the best non-email-domain display row. - */ +function canDisplayCollapsibleRow( + displayRow: T, + collapsibleRow: T, +): boolean { + if (!isCollapsibleMemberOrganization(displayRow)) { + return true + } + + return ( + getMemberOrganizationSourceRank(displayRow.source) < + getMemberOrganizationSourceRank(collapsibleRow.source) + ) +} + +/** Collapse overlapping email-domain and project-registry rows into one work experience for display. */ export function groupMemberOrganizations(rows: T[]): T[] { - const emailDomainRows = rows.filter((row) => row.source === OrganizationSource.EMAIL_DOMAIN) - const nonEmailRows = rows.filter((row) => row.source !== OrganizationSource.EMAIL_DOMAIN) - const hiddenEmailDomainIds = new Set() - const displayGroups = new Map() - - for (const emailDomainRow of emailDomainRows.filter( - (row): row is T & { id: string } => !!row.id, - )) { - const overlappingNonEmailRows = nonEmailRows.filter((row) => - memberOrganizationsOverlap(emailDomainRow, row), + const collapsibleRows = rows.filter( + (row): row is T & { id: string } => !!row.id && isCollapsibleMemberOrganization(row), + ) + const hiddenCollapsibleIds = new Set() + const collapsibleParentDisplayId = new Map() + const displayGroups = new Map() + + for (const collapsibleRow of collapsibleRows) { + const overlappingDisplayRows = rows.filter( + (row): row is T & { id: string } => + !!row.id && + row.id !== collapsibleRow.id && + memberOrganizationsOverlap(collapsibleRow, row) && + canDisplayCollapsibleRow(row, collapsibleRow), ) - if (overlappingNonEmailRows.length > 0) { - const displayRow = [...overlappingNonEmailRows].sort((a, b) => { - const rankDiff = - getMemberOrganizationSourceRank(a.source) - getMemberOrganizationSourceRank(b.source) - if (rankDiff !== 0) { - return rankDiff - } + if (overlappingDisplayRows.length > 0) { + const displayRow = [...overlappingDisplayRows].sort(compareMemberOrganizationsBySourceRank)[0] + hiddenCollapsibleIds.add(collapsibleRow.id) + collapsibleParentDisplayId.set(collapsibleRow.id, displayRow.id) + } + } - return (a.id ?? '').localeCompare(b.id ?? '') - })[0] + const resolveDisplayRowId = (collapsibleRowId: string): string => { + let displayRowId = collapsibleRowId + while (collapsibleParentDisplayId.has(displayRowId)) { + displayRowId = collapsibleParentDisplayId.get(displayRowId)! + } + return displayRowId + } - if (displayRow.id) { - hiddenEmailDomainIds.add(emailDomainRow.id) + for (const collapsibleRow of collapsibleRows) { + if (hiddenCollapsibleIds.has(collapsibleRow.id)) { + const displayRowId = resolveDisplayRowId(collapsibleRow.id) + const displayRow = rows.find((row): row is T & { id: string } => row.id === displayRowId) - const existingGroup = displayGroups.get(displayRow.id) + if (displayRow) { + const existingGroup = displayGroups.get(displayRowId) if (existingGroup) { - existingGroup.groupedEmailDomainRows.push(emailDomainRow) + existingGroup.groupedRows.push(collapsibleRow) } else { - displayGroups.set(displayRow.id, { + displayGroups.set(displayRowId, { displayRow, - groupedEmailDomainRows: [emailDomainRow], + groupedRows: [collapsibleRow], }) } } @@ -74,15 +123,14 @@ export function groupMemberOrganizations(rows: T[ } return rows - .filter((row): row is T & { id: string } => !!row.id && !hiddenEmailDomainIds.has(row.id)) + .filter((row): row is T & { id: string } => !!row.id && !hiddenCollapsibleIds.has(row.id)) .map((row) => { const group = displayGroups.get(row.id) if (!group) { return row } - // Preserve the visible row while surfacing the combined sources and date range - const groupedRows = [group.displayRow, ...group.groupedEmailDomainRows] + const groupedRows = [group.displayRow, ...group.groupedRows] const normalizedStarts = groupedRows .map((groupedRow) => normalizeMemberOrganizationDate(groupedRow.dateStart)) @@ -96,7 +144,12 @@ export function groupMemberOrganizations(rows: T[ for (const groupedRow of groupedRows) { if (groupedRow.source) { - sources.add(groupedRow.source) + for (const source of groupedRow.source.split(',')) { + const trimmed = source.trim() + if (trimmed) { + sources.add(trimmed) + } + } } } diff --git a/services/apps/git_integration/src/crowdgit/database/crud.py b/services/apps/git_integration/src/crowdgit/database/crud.py index b886b88efd..e98a25a06a 100644 --- a/services/apps/git_integration/src/crowdgit/database/crud.py +++ b/services/apps/git_integration/src/crowdgit/database/crud.py @@ -726,10 +726,9 @@ async def fetch_member_organizations(member_ids: list[str]) -> list[dict]: return await query( """ - SELECT "memberId", "organizationId", "dateStart", "dateEnd", source + SELECT "memberId", "organizationId", "dateStart", "dateEnd", source, "deletedAt" FROM "memberOrganizations" WHERE "memberId" = ANY($1::uuid[]) - AND "deletedAt" IS NULL """, (member_ids,), ) @@ -742,11 +741,10 @@ async def fetch_segment_affiliations(member_ids: list[str], segment_id: str) -> return await query( """ - SELECT "memberId", "segmentId", "organizationId", "dateStart", "dateEnd", verified + SELECT "memberId", "segmentId", "organizationId", "dateStart", "dateEnd", verified, "deletedAt" FROM "memberSegmentAffiliations" WHERE "memberId" = ANY($1::uuid[]) AND "segmentId" = $2::uuid - AND "deletedAt" IS NULL AND "organizationId" IS NOT NULL """, (member_ids, segment_id), diff --git a/services/apps/git_integration/src/crowdgit/services/affiliation/affiliation_service.py b/services/apps/git_integration/src/crowdgit/services/affiliation/affiliation_service.py index 2341316579..db7c56e35e 100644 --- a/services/apps/git_integration/src/crowdgit/services/affiliation/affiliation_service.py +++ b/services/apps/git_integration/src/crowdgit/services/affiliation/affiliation_service.py @@ -15,6 +15,8 @@ find_many_member_ids_by_identities, find_many_organization_ids_by_identities, get_repo_affiliation_registry, + insert_member_organizations, + insert_member_segment_affiliations, save_service_execution, upsert_repo_affiliation_registry, ) @@ -615,6 +617,44 @@ def has_existing_stint( return True return False + def is_blocked_by_deleted_row( + self, + deleted_rows: list[dict], + organization_id: str, + date_start: date | None, + date_end: date | None, + ) -> bool: + """True when a soft-deleted stint should not be re-created from the file.""" + incoming_undated = date_start is None and date_end is None + for row in deleted_rows: + if str(row["organizationId"]) != organization_id: + continue + + existing_start = row.get("dateStart") + existing_end = row.get("dateEnd") + if isinstance(existing_start, datetime): + existing_start = existing_start.date() + if isinstance(existing_end, datetime): + existing_end = existing_end.date() + + if incoming_undated: + if existing_start is None and existing_end is None: + return True + if self.is_undated_or_open_ended(existing_start, existing_end): + return True + continue + + if existing_start is None and existing_end is None: + continue + + min_date = date.min + max_date = date.max + if (existing_start or min_date) <= (date_end or max_date) and ( + existing_end or max_date + ) >= (date_start or min_date): + return True + return False + @staticmethod def affiliation_stint_key( contributor: AffiliationContributor, @@ -799,16 +839,30 @@ async def apply_affiliations( return member_ids_to_fetch = list({member_id for member_id, _, _ in resolved_stints}) - member_organizations = await fetch_member_organizations(member_ids_to_fetch) - segment_affiliations = await fetch_segment_affiliations(member_ids_to_fetch, segment_id) + all_member_organizations = await fetch_member_organizations(member_ids_to_fetch) + all_segment_affiliations = await fetch_segment_affiliations( + member_ids_to_fetch, segment_id + ) member_organizations_by_member: dict[str, list[dict]] = {} - for row in member_organizations: - member_organizations_by_member.setdefault(str(row["memberId"]), []).append(row) + deleted_member_organizations_by_member: dict[str, list[dict]] = {} + for row in all_member_organizations: + member_id = str(row["memberId"]) + if row.get("deletedAt"): + if row.get("source") != "project-registry": + continue + deleted_member_organizations_by_member.setdefault(member_id, []).append(row) + else: + member_organizations_by_member.setdefault(member_id, []).append(row) segment_affiliations_by_member: dict[str, list[dict]] = {} - for row in segment_affiliations: - segment_affiliations_by_member.setdefault(str(row["memberId"]), []).append(row) + deleted_segment_affiliations_by_member: dict[str, list[dict]] = {} + for row in all_segment_affiliations: + member_id = str(row["memberId"]) + if row.get("deletedAt"): + deleted_segment_affiliations_by_member.setdefault(member_id, []).append(row) + else: + segment_affiliations_by_member.setdefault(member_id, []).append(row) mo_inserts: list[dict] = [] msa_inserts: list[dict] = [] @@ -816,10 +870,16 @@ async def apply_affiliations( for member_id, organization_id, organization in resolved_stints: existing_mos = member_organizations_by_member.get(member_id, []) existing_msas = segment_affiliations_by_member.get(member_id, []) + deleted_mos = deleted_member_organizations_by_member.get(member_id, []) + deleted_msas = deleted_segment_affiliations_by_member.get(member_id, []) date_start = organization.date_start date_end = organization.date_end - if not self.has_existing_stint(existing_mos, organization_id, date_start, date_end): + if not self.has_existing_stint( + existing_mos, organization_id, date_start, date_end + ) and not self.is_blocked_by_deleted_row( + deleted_mos, organization_id, date_start, date_end + ): mo_inserts.append( { "member_id": member_id, @@ -830,7 +890,11 @@ async def apply_affiliations( } ) - if not self.has_existing_stint(existing_msas, organization_id, date_start, date_end): + if not self.has_existing_stint( + existing_msas, organization_id, date_start, date_end + ) and not self.is_blocked_by_deleted_row( + deleted_msas, organization_id, date_start, date_end + ): msa_inserts.append( { "member_id": member_id, @@ -841,9 +905,8 @@ async def apply_affiliations( } ) - # TODO: Enable CDP writes after testing (import insert_member_* from crud) - # await insert_member_organizations(mo_inserts) - # await insert_member_segment_affiliations(msa_inserts) + await insert_member_organizations(mo_inserts) + await insert_member_segment_affiliations(msa_inserts) async def process_affiliations( self, diff --git a/services/apps/members_enrichment_worker/src/activities/enrichment.ts b/services/apps/members_enrichment_worker/src/activities/enrichment.ts index c51021cb12..726375f659 100644 --- a/services/apps/members_enrichment_worker/src/activities/enrichment.ts +++ b/services/apps/members_enrichment_worker/src/activities/enrichment.ts @@ -815,8 +815,10 @@ function prepareWorkExperiences( newVersion: IMemberEnrichmentDataNormalizedOrganization[], isHighConfidenceSourceSelectedForWorkExperiences: boolean, ): IWorkExperienceChanges { - // we delete all the work experiences that were not manually created - const toDelete = oldVersion.filter((c) => c.source !== OrganizationSource.UI) + // we delete all the work experiences that were not manually created or from the project registry. + const toDelete = oldVersion.filter( + (c) => c.source !== OrganizationSource.UI && c.source !== OrganizationSource.PROJECT_REGISTRY, + ) const toCreate: IMemberEnrichmentDataNormalizedOrganization[] = [] // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/services/libs/common/src/member.ts b/services/libs/common/src/member.ts index 861970b90b..8168a31aee 100644 --- a/services/libs/common/src/member.ts +++ b/services/libs/common/src/member.ts @@ -91,9 +91,10 @@ export const calculateReach = (oldReach: any, newReach: any): { total: number } */ export function getMemberOrganizationSourceRank(source: string | null | undefined): number { if (source === OrganizationSource.UI) return 0 - if (source === OrganizationSource.EMAIL_DOMAIN) return 1 - if (source?.startsWith('enrichment-')) return 2 - return 3 + if (source === OrganizationSource.PROJECT_REGISTRY) return 1 + if (source === OrganizationSource.EMAIL_DOMAIN) return 2 + if (source?.startsWith('enrichment-')) return 3 + return 4 } /** diff --git a/services/libs/data-access-layer/src/members/segments.ts b/services/libs/data-access-layer/src/members/segments.ts index 8806ac8879..1774d07167 100644 --- a/services/libs/data-access-layer/src/members/segments.ts +++ b/services/libs/data-access-layer/src/members/segments.ts @@ -189,8 +189,9 @@ export async function findMemberManualAffiliation( AND ( ("dateStart" <= $(timestamp) AND "dateEnd" >= $(timestamp)) OR ("dateStart" <= $(timestamp) AND "dateEnd" IS NULL) + OR ("dateStart" IS NULL AND "dateEnd" IS NULL) ) - ORDER BY "dateStart" DESC, id + ORDER BY "dateStart" DESC NULLS LAST, id LIMIT 1 `, { diff --git a/services/libs/types/src/enums/organizations.ts b/services/libs/types/src/enums/organizations.ts index 3c5898f5ea..d2ddd41ab2 100644 --- a/services/libs/types/src/enums/organizations.ts +++ b/services/libs/types/src/enums/organizations.ts @@ -7,6 +7,7 @@ export enum OrganizationAttributeName { export enum OrganizationSource { EMAIL_DOMAIN = 'email-domain', + PROJECT_REGISTRY = 'project-registry', ENRICHMENT_PROGAI = 'enrichment-progai', ENRICHMENT_CLEARBIT = 'enrichment-clearbit', ENRICHMENT_CRUSTDATA = 'enrichment-crustdata',