Skip to content

Commit 77fcdcb

Browse files
authored
fix: dedupe overlapping email-domain member organizations (CM-1189) (#4225)
Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
1 parent 4aaeaf9 commit 77fcdcb

10 files changed

Lines changed: 462 additions & 61 deletions

File tree

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

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ import { signalMemberUpdate } from '@crowd/common_services'
77
import {
88
MemberField,
99
deleteMemberOrganizations,
10-
fetchManyMemberOrgsWithOrgData,
10+
fetchMemberOrganizations,
1111
findMemberById,
1212
optionsQx,
1313
} from '@crowd/data-access-layer'
1414

1515
import { noContent } from '@/utils/api'
16+
import { getOverlappingEmailDomainMemberOrganizations } from '@/utils/mapper'
1617
import { validateOrThrow } from '@/utils/validation'
1718

1819
const paramsSchema = z.object({
@@ -31,21 +32,31 @@ export async function deleteMemberWorkExperience(req: Request, res: Response): P
3132
throw new NotFoundError('Member not found')
3233
}
3334

34-
const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId])
35-
36-
const memberOrg = (orgsMap.get(memberId) ?? []).find((mo) => mo.id === workExperienceId)
35+
const memberOrgs = await fetchMemberOrganizations(qx, memberId)
36+
const memberOrg = memberOrgs.find((mo) => mo.id === workExperienceId)
3737

3838
if (!memberOrg) {
3939
throw new NotFoundError('Work experience not found')
4040
}
4141

42+
const overlappingEmailDomainRows = getOverlappingEmailDomainMemberOrganizations(
43+
memberOrgs,
44+
memberOrg,
45+
)
46+
47+
const memberOrgIdsToDelete = [
48+
workExperienceId,
49+
...overlappingEmailDomainRows.flatMap((row) => (row.id ? [row.id] : [])),
50+
]
51+
52+
// Delete hidden grouped rows with the visible row so read responses stay consistent
4253
await captureApiChange(
4354
req,
4455
memberEditOrganizationsAction(memberId, async (captureOldState, captureNewState) => {
4556
captureOldState(memberOrg)
4657

4758
await qx.tx(async (tx) => {
48-
await deleteMemberOrganizations(tx, memberId, [workExperienceId])
59+
await deleteMemberOrganizations(tx, memberId, memberOrgIdsToDelete)
4960
})
5061

5162
// Signal after commit so the workflow sees persisted changes

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from '@crowd/data-access-layer'
1111

1212
import { ok } from '@/utils/api'
13-
import { toMemberWorkExperience } from '@/utils/mapper'
13+
import { groupMemberOrganizations, toMemberWorkExperience } from '@/utils/mapper'
1414
import { validateOrThrow } from '@/utils/validation'
1515

1616
const paramsSchema = z.object({
@@ -28,7 +28,9 @@ export async function getMemberWorkExperiences(req: Request, res: Response): Pro
2828
}
2929

3030
const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId])
31-
const workExperiences = (orgsMap.get(memberId) ?? []).map(toMemberWorkExperience)
31+
const workExperiences = groupMemberOrganizations(orgsMap.get(memberId) ?? []).map(
32+
toMemberWorkExperience,
33+
)
3234

3335
ok(res, { memberId, workExperiences })
3436
}

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

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,19 @@ import {
88
MemberField,
99
cleanSoftDeletedMemberOrganization,
1010
fetchManyMemberOrgsWithOrgData,
11+
fetchMemberOrganizations,
1112
findMemberById,
1213
optionsQx,
1314
updateMemberOrganization,
1415
} from '@crowd/data-access-layer'
1516
import type { MemberOrganizationDateRange, MemberOrganizationUpdate } from '@crowd/types'
1617

1718
import { ok } from '@/utils/api'
18-
import { toMemberWorkExperience } from '@/utils/mapper'
19+
import {
20+
getOverlappingEmailDomainMemberOrganizations,
21+
groupMemberOrganizations,
22+
toMemberWorkExperience,
23+
} from '@/utils/mapper'
1924
import { validateOrThrow } from '@/utils/validation'
2025

2126
const paramsSchema = z.object({
@@ -45,8 +50,8 @@ export async function updateMemberWorkExperience(req: Request, res: Response): P
4550
throw new NotFoundError('Member not found')
4651
}
4752

48-
const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId])
49-
const existing = (orgsMap.get(memberId) ?? []).find((mo) => mo.id === workExperienceId)
53+
const memberOrgs = await fetchMemberOrganizations(qx, memberId)
54+
const existing = memberOrgs.find((mo) => mo.id === workExperienceId)
5055

5156
if (!existing) {
5257
throw new NotFoundError('Work experience not found')
@@ -80,6 +85,34 @@ export async function updateMemberWorkExperience(req: Request, res: Response): P
8085
await qx.tx(async (tx) => {
8186
await cleanSoftDeletedMemberOrganization(tx, memberId, data.organizationId, update)
8287
await updateMemberOrganization(tx, memberId, workExperienceId, update)
88+
89+
const overlapBasis = { ...existing, ...update }
90+
91+
const overlappingEmailDomainRows = getOverlappingEmailDomainMemberOrganizations(
92+
memberOrgs,
93+
overlapBasis,
94+
)
95+
96+
const groupedUpdate: MemberOrganizationUpdate = {}
97+
98+
// Keep grouped rows aligned for shared display fields; dates stay on the edited row
99+
if (data.jobTitle !== undefined) {
100+
groupedUpdate.title = data.jobTitle
101+
}
102+
if (data.verified !== undefined) {
103+
groupedUpdate.verified = data.verified
104+
}
105+
if (data.verifiedBy !== undefined) {
106+
groupedUpdate.verifiedBy = data.verifiedBy
107+
}
108+
109+
if (overlappingEmailDomainRows.length > 0 && Object.keys(groupedUpdate).length > 0) {
110+
for (const overlappingRow of overlappingEmailDomainRows.filter(
111+
(row): row is typeof row & { id: string } => !!row.id,
112+
)) {
113+
await updateMemberOrganization(tx, memberId, overlappingRow.id, groupedUpdate)
114+
}
115+
}
83116
})
84117

85118
// Signal after commit so the workflow sees persisted changes
@@ -88,7 +121,10 @@ export async function updateMemberWorkExperience(req: Request, res: Response): P
88121
})
89122

90123
const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId])
91-
const updatedMo = (orgsMap.get(memberId) ?? []).find((mo) => mo.id === workExperienceId)
124+
125+
const updatedMo = groupMemberOrganizations(orgsMap.get(memberId) ?? []).find(
126+
(mo) => mo.id === workExperienceId,
127+
)
92128

93129
if (!updatedMo) {
94130
throw new NotFoundError('Work experience not found')

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

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,19 @@ import {
88
MemberField,
99
deleteMemberOrganizations,
1010
fetchManyMemberOrgsWithOrgData,
11+
fetchMemberOrganizations,
1112
findMemberById,
1213
optionsQx,
1314
updateMemberOrganization,
1415
} from '@crowd/data-access-layer'
15-
import { IMemberOrganization } from '@crowd/types'
16+
import { IMemberOrganization, IMemberRoleWithOrganization } from '@crowd/types'
1617

1718
import { ok } from '@/utils/api'
18-
import { toMemberWorkExperience } from '@/utils/mapper'
19+
import {
20+
getOverlappingEmailDomainMemberOrganizations,
21+
groupMemberOrganizations,
22+
toMemberWorkExperience,
23+
} from '@/utils/mapper'
1924
import { validateOrThrow } from '@/utils/validation'
2025

2126
const paramsSchema = z.object({
@@ -40,13 +45,25 @@ export async function verifyMemberWorkExperience(req: Request, res: Response): P
4045
throw new NotFoundError('Member not found')
4146
}
4247

43-
const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId])
44-
const memberOrg = (orgsMap.get(memberId) ?? []).find((mo) => mo.id === workExperienceId)
48+
const memberOrgs = await fetchMemberOrganizations(qx, memberId)
49+
const memberOrg = memberOrgs.find((mo) => mo.id === workExperienceId)
4550

4651
if (!memberOrg) {
4752
throw new NotFoundError('Work experience not found')
4853
}
4954

55+
const overlappingEmailDomainRows = getOverlappingEmailDomainMemberOrganizations(
56+
memberOrgs,
57+
memberOrg,
58+
)
59+
60+
const memberOrgIdsToDelete = [
61+
workExperienceId,
62+
...overlappingEmailDomainRows.flatMap((row) => (row.id ? [row.id] : [])),
63+
]
64+
65+
const verifiedUpdate = { verified, verifiedBy }
66+
5067
let updatedMemberOrg: IMemberOrganization | undefined
5168

5269
await captureApiChange(
@@ -56,12 +73,22 @@ export async function verifyMemberWorkExperience(req: Request, res: Response): P
5673

5774
await qx.tx(async (tx) => {
5875
if (verified) {
59-
updatedMemberOrg = await updateMemberOrganization(tx, memberId, workExperienceId, {
60-
verified,
61-
verifiedBy,
62-
})
76+
// Verification status belongs to the grouped work experience, not just the visible row
77+
updatedMemberOrg = await updateMemberOrganization(
78+
tx,
79+
memberId,
80+
workExperienceId,
81+
verifiedUpdate,
82+
)
83+
84+
for (const overlappingRow of overlappingEmailDomainRows.filter(
85+
(row): row is typeof row & { id: string } => !!row.id,
86+
)) {
87+
await updateMemberOrganization(tx, memberId, overlappingRow.id, verifiedUpdate)
88+
}
6389
} else {
64-
await deleteMemberOrganizations(tx, memberId, [workExperienceId], true)
90+
// Unverifying removes the grouped work experience from both visible and hidden rows
91+
await deleteMemberOrganizations(tx, memberId, memberOrgIdsToDelete, true)
6592
}
6693
})
6794

@@ -76,5 +103,13 @@ export async function verifyMemberWorkExperience(req: Request, res: Response): P
76103
}),
77104
)
78105

79-
ok(res, toMemberWorkExperience({ ...memberOrg, ...updatedMemberOrg }))
106+
const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId])
107+
108+
const responseMo: IMemberRoleWithOrganization =
109+
groupMemberOrganizations(orgsMap.get(memberId) ?? []).find(
110+
(mo) => mo.id === workExperienceId,
111+
) ??
112+
({ ...memberOrg, ...updatedMemberOrg, verified, verifiedBy } as IMemberRoleWithOrganization)
113+
114+
ok(res, toMemberWorkExperience(responseMo))
80115
}

backend/src/services/member/memberAffiliationsService.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import { uniq } from 'lodash'
33

44
import { Error400, dateIntersects, groupBy } from '@crowd/common'
55
import { signalMemberUpdate } from '@crowd/common_services'
6+
import {
7+
changeMemberOrganizationAffiliationOverrides,
8+
fetchMemberOrganizations,
9+
findMemberAffiliationOverrides,
10+
} from '@crowd/data-access-layer'
611
import { findMaintainerRoles } from '@crowd/data-access-layer/src/maintainers'
12+
import { deleteMemberSegmentAffiliations } from '@crowd/data-access-layer/src/member_segment_affiliations'
713
import { fetchManySegments } from '@crowd/data-access-layer/src/segments'
814
import { LoggerBase } from '@crowd/logging'
915
import {
@@ -13,8 +19,8 @@ import {
1319
} from '@crowd/types'
1420

1521
import MemberAffiliationsRepository from '@/database/repositories/member/memberAffiliationsRepository'
16-
import MemberOrganizationAffiliationOverridesRepository from '@/database/repositories/member/memberOrganizationAffiliationOverridesRepository'
1722
import SequelizeRepository from '@/database/repositories/sequelizeRepository'
23+
import { getOverlappingEmailDomainMemberOrganizations } from '@/utils/mapper'
1824

1925
import { IServiceOptions } from '../IServiceOptions'
2026

@@ -102,11 +108,42 @@ export default class MemberAffiliationsService extends LoggerBase {
102108
}
103109
}
104110

105-
const override = await MemberOrganizationAffiliationOverridesRepository.changeOverride(
106-
data,
107-
this.options,
111+
const qx = SequelizeRepository.getQueryExecutor(this.options)
112+
113+
const memberOrgs = await fetchMemberOrganizations(qx, data.memberId)
114+
const memberOrg = memberOrgs.find((mo) => mo.id === data.memberOrganizationId)
115+
116+
const overlappingEmailDomainRows = memberOrg
117+
? getOverlappingEmailDomainMemberOrganizations(memberOrgs, memberOrg)
118+
: []
119+
120+
const memberOrgIds = [
121+
data.memberOrganizationId,
122+
...overlappingEmailDomainRows.flatMap((row) => (row.id ? [row.id] : [])),
123+
]
124+
125+
// Apply the override to hidden grouped rows so the merged work experience has one decision
126+
await changeMemberOrganizationAffiliationOverrides(
127+
qx,
128+
memberOrgIds.map((memberOrganizationId) => ({
129+
...data,
130+
memberOrganizationId,
131+
})),
108132
)
109133

134+
if (data.allowAffiliation === false && memberOrg?.organizationId) {
135+
await deleteMemberSegmentAffiliations(qx, {
136+
memberId: data.memberId,
137+
organizationId: memberOrg.organizationId,
138+
})
139+
}
140+
141+
const overrides = await findMemberAffiliationOverrides(qx, data.memberId, [
142+
data.memberOrganizationId,
143+
])
144+
145+
const override = overrides[0]
146+
110147
await signalMemberUpdate(this.options.temporal, data.memberId)
111148

112149
return override

0 commit comments

Comments
 (0)