Skip to content

feat: filter deleted member enrchiment worker (CM-1367) - #4476

Open
ulemons wants to merge 5 commits into
mainfrom
feat/filter-deleted-member-enrchiment-worker
Open

feat: filter deleted member enrchiment worker (CM-1367)#4476
ulemons wants to merge 5 commits into
mainfrom
feat/filter-deleted-member-enrchiment-worker

Conversation

@ulemons

@ulemons ulemons commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Refactors members_enrichment_worker's org-affiliation reconciliation from delete-and-recreate to update-in-place. Today, every enrichment run deletes every non-UI/non-project-registry memberOrganizations row and recreates whatever the provider supplies, which causes two bugs: (1) an org a human manually deleted because it was wrong (e.g. the "Dusky'z" case — OpenDaylight/Apache/Karaf contributions wrongly attributed to a non-existent company instead of PANTHEON.tech) gets resupplied and recreated by the provider on the next run, and (2) verified/verifiedBy status is lost every time, since the recreated row is always unverified. This PR makes enrichment update rows in place, so verification and human deletes survive reruns.

Changes

  • New "deletedBy" column on "memberOrganizations" (migration, additive, no backfill). Set only on human-initiated deletes; NULL means the delete was enrichment's own rebuild and stays recoverable (protects against provider-glitch data loss).
  • deleteMemberOrganizations() (data-access-layer) takes an optional deletedBy, wired through the human-delete paths: verifyMemberWorkExperience.ts (unverify) and memberOrganizationsService.ts#delete (CDP UI org delete). The enrichment worker's own delete path (deleteMemberOrgById) is untouched — it must keep leaving deletedBy NULL.
  • fetchMemberDataForLLMSquashing's tombstone CTE now keys off "deletedAt" IS NOT NULL AND "deletedBy" IS NOT NULL instead of guessing from source; also selects verified/"verifiedBy" so the worker can protect verified rows.
  • Extracted workExperienceReconciliation.ts from enrichment.ts (pure, unit-testable). prepareWorkExperiences now reconciles old vs. new orgs by (organizationId, normalized title): matched rows are updated in place (only the fields that changed), unmatched old rows are soft-deleted (deletedBy NULL, recoverable), unmatched new entries are inserted, and orgs with deletedBy set are never recreated. UI, project-registry, and verified = true rows are never touched.
  • affiliationNeedsRefresh narrowed to fire only on an actual date change, not a cosmetic title-only update — cuts down noise on activityRelations refreshes.
  • updateMemberOrg now sets "updatedAt" = now() on every update.
  • Added unit tests for prepareWorkExperiences / hasMemberOrganizationTimelineChange (14 cases): verified-row protection, tombstone respect, provider-glitch recoverability, no writes on unchanged payload, UI-row conflict handling in both the normal and high-confidence branches.

Verified end-to-end locally, not just unit tests: seeded a member with a verified affiliation, a manually-tombstoned org, and a provider-soft-deleted (non-tombstoned) org, then ran the real processMemberSources Temporal workflow against it. Result matched all three expectations — verified row untouched, tombstoned org never recreated, provider-glitch org recreated as a new active row.

Known gap, intentionally out of scope — tracked in a separate ticket: the public API v1 DELETE /members/:memberId/work-experiences/:workExperienceId has no actor field (it's an M2M token, so req.actor carries the calling LFXOne service's client id, not the acting human). Deletes through that endpoint won't be tombstoned yet. Confirmed with Nathan and Joana to handle this separately since it needs LFXOne coordination on the payload contract.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Performance improvement
  • Chore / dependency update
  • Documentation

JIRA ticket

CM-1367

@ulemons ulemons self-assigned this Aug 13, 2026
Copilot AI balanced review requested due to automatic review settings August 13, 2026 17:18
@ulemons ulemons added the Bug Created by Linear-GitHub Sync label Aug 13, 2026
@cursor

cursor Bot commented Aug 13, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches the core member enrichment write path and affiliation refresh triggers; behavior is well covered by new unit tests but incorrect matching could still mis-apply org timelines at scale.

Overview
Stops enrichment from wiping and recreating member work affiliations on every run. Human deletes and verified rows now stick across provider reruns.

Adds deletedBy on memberOrganizations (migration). Only CDP/UI human delete paths set it when soft-deleting (memberOrganizationsService, unverify in verifyMemberWorkExperience). Enrichment’s own deletes still leave deletedBy null so provider glitches stay recoverable. LLM squashing loads deletedOrganizations from rows with both deletedAt and deletedBy set.

prepareWorkExperiences moves to workExperienceReconciliation.ts: match provider payload to existing enrichment-owned rows, update changed fields in place, soft-delete dropped rows without tombstoning, skip tombstoned orgs and verified/UI/project-registry rows, with vitest coverage. affiliationNeedsRefresh now keys off real date timeline changes, not title-only updates. Member org updates also bump updatedAt.

Reviewed by Cursor Bugbot for commit 7bbf97e. Bugbot is set up for automated code reviews on this repo. Configure here.

Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
@ulemons
ulemons force-pushed the feat/filter-deleted-member-enrchiment-worker branch from 572faa1 to d21e705 Compare August 13, 2026 17:19

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit d21e705. Configure here.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors member work-experience enrichment to preserve verified affiliations and human-deletion tombstones.

Changes:

  • Adds deletedBy tombstones and wires human deletion paths.
  • Reconciles affiliations in place instead of recreating them.
  • Adds Vitest coverage for reconciliation behavior.

Reviewed changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
services/libs/types/src/enrichment.ts Extends enrichment affiliation types.
services/libs/data-access-layer/src/old/apps/members_enrichment_worker/index.ts Fetches verification and tombstone data.
services/libs/data-access-layer/src/members/organizations.ts Records deletion actors and update timestamps.
services/apps/members_enrichment_worker/vitest.config.ts Configures Vitest.
services/apps/members_enrichment_worker/src/activities/workExperienceReconciliation.ts Implements in-place reconciliation.
services/apps/members_enrichment_worker/src/activities/workExperienceReconciliation.test.ts Tests reconciliation scenarios.
services/apps/members_enrichment_worker/src/activities/enrichment.ts Integrates reconciliation into enrichment.
services/apps/members_enrichment_worker/package.json Adds the test command and Vitest.
pnpm-lock.yaml Locks the Vitest dependency.
backend/src/services/member/memberOrganizationsService.ts Tombstones UI deletions.
backend/src/database/migrations/V1786621970__add-deleted-by-to-member-organizations.sql Adds the deletedBy column.
backend/src/api/public/v1/members/work-experiences/verifyMemberWorkExperience.ts Tombstones rejected work experiences.
Files not reviewed (1)
  • pnpm-lock.yaml: Generated file

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread services/libs/data-access-layer/src/members/organizations.ts Outdated
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
Copilot AI review requested due to automatic review settings August 13, 2026 17:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 12 changed files in this pull request and generated 1 comment.

Files not reviewed (1)
  • pnpm-lock.yaml: Generated file
Suppressed comments (9)

services/apps/members_enrichment_worker/src/activities/workExperienceReconciliation.ts:127

  • This checks identity.organizationId, but the enrichment flow resolves an existing organization by setting only entry.organizationId (enrichment.ts:421-430). Those identity IDs can remain unset, so a provider row with changed dates bypasses this filter and is inserted beside the verified row. Match the resolved entry organization ID directly.
      !verifiedRows.some(
        (v) =>
          normalizeTitle(v.jobTitle) === normalizeTitle(e.title) &&
          e.identities &&
          e.identities.some((i) => i.organizationId === v.orgId),

services/apps/members_enrichment_worker/src/activities/workExperienceReconciliation.ts:139

  • This UI conflict check has the same mismatch: existing-org resolution populates e.organizationId but does not populate each identity's organizationId. In that normal path, a high-confidence provider entry is not recognized as the existing UI experience and can create a duplicate with different dates.
        !uiEntries.some(
          (ui) =>
            e.title === ui.jobTitle &&
            e.identities &&
            e.identities.some((i) => i.organizationId === ui.orgId),

services/apps/members_enrichment_worker/src/activities/workExperienceReconciliation.ts:184

  • Existing-organization matching leaves identity-level organization IDs unset, so this lookup can miss a resolved UI row and retain the provider entry. Compare the entry's resolved organizationId; otherwise the normal-confidence branch can insert a conflicting duplicate instead of suppressing it.
    const match = orderedNewVersion.find(
      (e) =>
        e.title === current.jobTitle &&
        e.identities &&
        e.identities.some((e) => e.organizationId === current.orgId),
    )

services/apps/members_enrichment_worker/src/activities/workExperienceReconciliation.ts:115

  • This comment restates the tombstone filter directly below it. The condition is self-explanatory, so the narration should be removed.
  // never recreate an affiliation that a person deleted on purpose — providers keep resupplying it

services/apps/members_enrichment_worker/src/activities/workExperienceReconciliation.ts:119

  • This comment narrates why the following filter exists rather than expressing the rule in the code. Remove it; the verified-row selection and conflict predicate should carry the behavior.
  // verified rows are excluded from oldEnrichmentRows above, so a matching provider entry
  // must be dropped here too, or it lands in toCreate as a conflicting duplicate

services/libs/data-access-layer/src/old/apps/members_enrichment_worker/index.ts:48

  • Selecting verified here does not protect affiliations whose organization has no identities, because the CTE still inner-joins organizationIdentities. Organizations can be created from a name alone, so such verified rows disappear from oldVersion and a changed-date provider row can be inserted beside them. Use a left join so every active member-organization row is reconciled.
                            mo.verified,
                            mo."verifiedBy",

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

  • Title and source changes can alter the affiliation timeline even when dates stay fixed: timeline preparation excludes titles such as Investor/Mentor/Board Member and ranks overlapping rows by source (member-organization-affiliation/index.ts:113-123,300-307). Suppressing refreshes for those updates can leave activityRelations assigned using stale eligibility or precedence.
      const toUpdateHasTimelineChange = Array.from(results.toUpdate.values()).some(
        (fields) => 'dateStart' in fields || 'dateEnd' in fields,
      )

services/libs/data-access-layer/src/members/organizations.ts:521

  • The optional parameter is tested by truthiness, but the public unverify schema accepts an empty string. Passing deletedBy: '' therefore performs the delete without recording the tombstone, allowing enrichment to recreate the affiliation. Distinguish omission from an explicitly supplied value.
    ? deletedBy

services/apps/members_enrichment_worker/src/activities/workExperienceReconciliation.ts:107

  • These lines describe the filter immediately below and do not document an external quirk or a caller-facing invariant. Remove the explanatory comment and let the source predicates express the protected-row rule.

This issue also appears in the following locations of the same file:

  • line 115
  • line 118
  // UI and project-registry rows are manual input; a verified row is a human decision too —
  // the worker never deletes or updates any of them.

Comment thread services/apps/members_enrichment_worker/src/activities/enrichment.ts Outdated
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
Copilot AI review requested due to automatic review settings August 13, 2026 17:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 12 changed files in this pull request and generated 1 comment.

Files not reviewed (1)
  • pnpm-lock.yaml: Generated file
Suppressed comments (5)

services/apps/members_enrichment_worker/src/activities/workExperienceReconciliation.ts:127

  • This check relies on identity objects carrying organizationId, but production-normalized provider identities do not set it when the organization is matched through org.organizationId (see enrichment.ts:421-430). Consequently, a provider entry with changed dates is not filtered and an unverified duplicate can be inserted alongside the verified row. Match the resolved organization ID directly.
      !verifiedRows.some(
        (v) =>
          normalizeTitle(v.jobTitle) === normalizeTitle(e.title) &&
          e.identities &&
          e.identities.some((i) => i.organizationId === v.orgId),

services/apps/members_enrichment_worker/src/activities/workExperienceReconciliation.ts:139

  • Production provider identities are not stamped with organizationId when an existing organization is resolved (enrichment.ts:421-430), so this high-confidence filter misses matching UI rows. If the provider dates differ, reconciliation creates an enrichment-owned duplicate instead of preserving the UI-only experience. Compare the already-resolved e.organizationId directly.
        !uiEntries.some(
          (ui) =>
            e.title === ui.jobTitle &&
            e.identities &&
            e.identities.some((i) => i.organizationId === ui.orgId),

services/apps/members_enrichment_worker/src/activities/workExperienceReconciliation.ts:183

  • As in the high-confidence branch, real normalized identities can lack organizationId even after e.organizationId has been resolved. This prevents matching the provider entry to its UI row and can create a second affiliation when dates differ. Use the resolved organization ID for this comparison.
    const match = orderedNewVersion.find(
      (e) =>
        e.title === current.jobTitle &&
        e.identities &&
        e.identities.some((e) => e.organizationId === current.orgId),

services/libs/data-access-layer/src/members/organizations.ts:521

  • The API schema accepts an empty verifiedBy string, but this truthiness check treats it as if no actor was supplied. Such an unverify operation is soft-deleted without a tombstone and enrichment can recreate it on the next run. Distinguish omission from an explicitly supplied string.
    ? deletedBy

services/apps/members_enrichment_worker/src/activities/workExperienceReconciliation.ts:20

  • This JSDoc restates the function's implementation and violates the repository guideline that code should be self-explanatory without descriptive comments. The function name and tuple construction already convey this behavior.
/**
 * Returns true when the set of (orgId, startDate, endDate) tuples differs
 * between deletes and creates. Fields like title or source don't affect
 * the affiliation timeline, so they're intentionally ignored.
 */

Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
Copilot AI review requested due to automatic review settings August 13, 2026 18:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 12 changed files in this pull request and generated 1 comment.

Files not reviewed (1)
  • pnpm-lock.yaml: Generated file
Suppressed comments (2)

services/apps/members_enrichment_worker/src/activities/workExperienceReconciliation.ts:133

  • Entries matched to an existing organization only get entry.organizationId populated; their identity array may remain empty. In that supported case this predicate misses the verified row, so a provider stint with different dates can be inserted as an unverified duplicate. Match the already-resolved organization ID directly.
      !verifiedRows.some(
        (v) =>
          normalizeTitle(v.jobTitle) === normalizeTitle(e.title) &&
          e.identities &&
          e.identities.some((i) => i.organizationId === v.orgId),

services/libs/data-access-layer/src/members/organizations.ts:522

  • The verification endpoint accepts any z.string(), including ''. With this truthiness check, such an explicit human delete omits deletedBy and enrichment can recreate the affiliation. Distinguish an omitted actor from an empty value (or reject empty actors at validation).
    ? deletedBy
      ? 'UPDATE "memberOrganizations" SET "deletedAt" = NOW(), "deletedBy" = $(deletedBy)'

Comment on lines +96 to +98
if (Object.keys(toUpdateInner).length > 0) {
toUpdate.set(match, toUpdateInner)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug Created by Linear-GitHub Sync

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants