Skip to content

Commit 827c956

Browse files
authored
feat: enable project-registry affiliation writes (CM-361) (#4302)
Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
1 parent 25f5f29 commit 827c956

12 files changed

Lines changed: 200 additions & 90 deletions

File tree

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

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
} from '@crowd/data-access-layer'
1414

1515
import { noContent } from '@/utils/api'
16-
import { getOverlappingEmailDomainMemberOrganizations } from '@/utils/mapper'
16+
import { getOverlappingGroupedMemberOrganizations } from '@/utils/mapper'
1717
import { validateOrThrow } from '@/utils/validation'
1818

1919
const paramsSchema = z.object({
@@ -39,14 +39,11 @@ export async function deleteMemberWorkExperience(req: Request, res: Response): P
3939
throw new NotFoundError('Work experience not found')
4040
}
4141

42-
const overlappingEmailDomainRows = getOverlappingEmailDomainMemberOrganizations(
43-
memberOrgs,
44-
memberOrg,
45-
)
42+
const overlappingGroupedRows = getOverlappingGroupedMemberOrganizations(memberOrgs, memberOrg)
4643

4744
const memberOrgIdsToDelete = [
4845
workExperienceId,
49-
...overlappingEmailDomainRows.flatMap((row) => (row.id ? [row.id] : [])),
46+
...overlappingGroupedRows.flatMap((row) => (row.id ? [row.id] : [])),
5047
]
5148

5249
// Delete hidden grouped rows with the visible row so read responses stay consistent

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import type { MemberOrganizationDateRange, MemberOrganizationUpdate } from '@cro
1717

1818
import { ok } from '@/utils/api'
1919
import {
20-
getOverlappingEmailDomainMemberOrganizations,
20+
getOverlappingGroupedMemberOrganizations,
2121
groupMemberOrganizations,
2222
toMemberWorkExperience,
2323
} from '@/utils/mapper'
@@ -88,7 +88,7 @@ export async function updateMemberWorkExperience(req: Request, res: Response): P
8888

8989
const overlapBasis = { ...existing, ...update }
9090

91-
const overlappingEmailDomainRows = getOverlappingEmailDomainMemberOrganizations(
91+
const overlappingGroupedRows = getOverlappingGroupedMemberOrganizations(
9292
memberOrgs,
9393
overlapBasis,
9494
)
@@ -106,8 +106,8 @@ export async function updateMemberWorkExperience(req: Request, res: Response): P
106106
groupedUpdate.verifiedBy = data.verifiedBy
107107
}
108108

109-
if (overlappingEmailDomainRows.length > 0 && Object.keys(groupedUpdate).length > 0) {
110-
for (const overlappingRow of overlappingEmailDomainRows.filter(
109+
if (overlappingGroupedRows.length > 0 && Object.keys(groupedUpdate).length > 0) {
110+
for (const overlappingRow of overlappingGroupedRows.filter(
111111
(row): row is typeof row & { id: string } => !!row.id,
112112
)) {
113113
await updateMemberOrganization(tx, memberId, overlappingRow.id, groupedUpdate)

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

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { IMemberOrganization, IMemberRoleWithOrganization } from '@crowd/types'
1717

1818
import { ok } from '@/utils/api'
1919
import {
20-
getOverlappingEmailDomainMemberOrganizations,
20+
getOverlappingGroupedMemberOrganizations,
2121
groupMemberOrganizations,
2222
toMemberWorkExperience,
2323
} from '@/utils/mapper'
@@ -52,14 +52,11 @@ export async function verifyMemberWorkExperience(req: Request, res: Response): P
5252
throw new NotFoundError('Work experience not found')
5353
}
5454

55-
const overlappingEmailDomainRows = getOverlappingEmailDomainMemberOrganizations(
56-
memberOrgs,
57-
memberOrg,
58-
)
55+
const overlappingGroupedRows = getOverlappingGroupedMemberOrganizations(memberOrgs, memberOrg)
5956

6057
const memberOrgIdsToDelete = [
6158
workExperienceId,
62-
...overlappingEmailDomainRows.flatMap((row) => (row.id ? [row.id] : [])),
59+
...overlappingGroupedRows.flatMap((row) => (row.id ? [row.id] : [])),
6360
]
6461

6562
const verifiedUpdate = { verified, verifiedBy }
@@ -81,7 +78,7 @@ export async function verifyMemberWorkExperience(req: Request, res: Response): P
8178
verifiedUpdate,
8279
)
8380

84-
for (const overlappingRow of overlappingEmailDomainRows.filter(
81+
for (const overlappingRow of overlappingGroupedRows.filter(
8582
(row): row is typeof row & { id: string } => !!row.id,
8683
)) {
8784
await updateMemberOrganization(tx, memberId, overlappingRow.id, verifiedUpdate)

backend/src/services/member/memberAffiliationsService.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020

2121
import MemberAffiliationsRepository from '@/database/repositories/member/memberAffiliationsRepository'
2222
import SequelizeRepository from '@/database/repositories/sequelizeRepository'
23-
import { getOverlappingEmailDomainMemberOrganizations } from '@/utils/mapper'
23+
import { getOverlappingGroupedMemberOrganizations } from '@/utils/mapper'
2424

2525
import { IServiceOptions } from '../IServiceOptions'
2626

@@ -113,13 +113,13 @@ export default class MemberAffiliationsService extends LoggerBase {
113113
const memberOrgs = await fetchMemberOrganizations(qx, data.memberId)
114114
const memberOrg = memberOrgs.find((mo) => mo.id === data.memberOrganizationId)
115115

116-
const overlappingEmailDomainRows = memberOrg
117-
? getOverlappingEmailDomainMemberOrganizations(memberOrgs, memberOrg)
116+
const overlappingGroupedRows = memberOrg
117+
? getOverlappingGroupedMemberOrganizations(memberOrgs, memberOrg)
118118
: []
119119

120120
const memberOrgIds = [
121121
data.memberOrganizationId,
122-
...overlappingEmailDomainRows.flatMap((row) => (row.id ? [row.id] : [])),
122+
...overlappingGroupedRows.flatMap((row) => (row.id ? [row.id] : [])),
123123
]
124124

125125
// Apply the override to hidden grouped rows so the merged work experience has one decision

backend/src/services/member/memberOrganizationsService.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,7 @@ import {
2828
} from '@crowd/types'
2929

3030
import SequelizeRepository from '@/database/repositories/sequelizeRepository'
31-
import {
32-
getOverlappingEmailDomainMemberOrganizations,
33-
groupMemberOrganizations,
34-
} from '@/utils/mapper'
31+
import { getOverlappingGroupedMemberOrganizations, groupMemberOrganizations } from '@/utils/mapper'
3532

3633
import { IServiceOptions } from '../IServiceOptions'
3734

@@ -106,12 +103,12 @@ export default class MemberOrganizationsService extends LoggerBase {
106103
const allOrganizations = groupedMemberOrganizations
107104
.filter((mo): mo is typeof mo & { id: string } => !!mo.id && !!orgById[mo.organizationId])
108105
.map((mo) => {
109-
const overlappingEmailDomainRows = getOverlappingEmailDomainMemberOrganizations(
106+
const overlappingGroupedRows = getOverlappingGroupedMemberOrganizations(
110107
memberOrganizations,
111108
mo,
112109
)
113110

114-
const relatedIds = [mo.id, ...overlappingEmailDomainRows.map((row) => row.id)]
111+
const relatedIds = [mo.id, ...overlappingGroupedRows.map((row) => row.id)]
115112

116113
const relatedOverrides = relatedIds.map((memberOrganizationId) =>
117114
overridesByMemberOrganizationId.get(memberOrganizationId),
@@ -294,7 +291,7 @@ export default class MemberOrganizationsService extends LoggerBase {
294291

295292
const overlapBasis = { ...existing, ...update }
296293

297-
const overlappingEmailDomainRows = getOverlappingEmailDomainMemberOrganizations(
294+
const overlappingGroupedRows = getOverlappingGroupedMemberOrganizations(
298295
memberOrganizations,
299296
overlapBasis,
300297
)
@@ -309,8 +306,8 @@ export default class MemberOrganizationsService extends LoggerBase {
309306
(value) => value !== undefined,
310307
) as MemberOrganizationUpdate
311308

312-
if (overlappingEmailDomainRows.length > 0 && Object.keys(groupedUpdate).length > 0) {
313-
for (const overlappingRow of overlappingEmailDomainRows) {
309+
if (overlappingGroupedRows.length > 0 && Object.keys(groupedUpdate).length > 0) {
310+
for (const overlappingRow of overlappingGroupedRows) {
314311
if (!overlappingRow.id) {
315312
continue
316313
}
@@ -355,14 +352,14 @@ export default class MemberOrganizationsService extends LoggerBase {
355352
throw new Error404(`Member organization with id ${id} not found!`)
356353
}
357354

358-
const overlappingEmailDomainRows = getOverlappingEmailDomainMemberOrganizations(
355+
const overlappingGroupedRows = getOverlappingGroupedMemberOrganizations(
359356
existingMemberOrganizations,
360357
memberOrganizationToBeDeleted,
361358
)
362359

363360
const memberOrganizationIdsToDelete = [
364361
id,
365-
...overlappingEmailDomainRows.flatMap((row) => (row.id ? [row.id] : [])),
362+
...overlappingGroupedRows.flatMap((row) => (row.id ? [row.id] : [])),
366363
]
367364

368365
// Delete hidden grouped rows with the visible row so list responses stay consistent

backend/src/utils/mapper.ts

Lines changed: 90 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -15,74 +15,122 @@ function memberOrganizationsOverlap<T extends IMemberOrganization>(a: T, b: T):
1515
)
1616
}
1717

18-
/**
19-
* Finds email-domain rows that are represented by the same visible work experience.
20-
*/
21-
export function getOverlappingEmailDomainMemberOrganizations<T extends IMemberOrganization>(
18+
function isCollapsibleMemberOrganization<T extends IMemberOrganization>(row: T): boolean {
19+
if (!row.source) {
20+
return false
21+
}
22+
23+
return row.source
24+
.split(',')
25+
.map((value) => value.trim())
26+
.some((value) =>
27+
[OrganizationSource.EMAIL_DOMAIN, OrganizationSource.PROJECT_REGISTRY].includes(
28+
value as OrganizationSource,
29+
),
30+
)
31+
}
32+
33+
function compareMemberOrganizationsBySourceRank(
34+
a: IMemberOrganization,
35+
b: IMemberOrganization,
36+
): number {
37+
const rankDiff =
38+
getMemberOrganizationSourceRank(a.source) - getMemberOrganizationSourceRank(b.source)
39+
if (rankDiff !== 0) {
40+
return rankDiff
41+
}
42+
43+
return (a.id ?? '').localeCompare(b.id ?? '')
44+
}
45+
46+
/** Hidden inferential rows that overlap a visible work experience (for delete/update/override). */
47+
export function getOverlappingGroupedMemberOrganizations<T extends IMemberOrganization>(
2248
rows: T[],
2349
memberOrganization: T,
2450
): T[] {
2551
return rows.filter(
2652
(row) =>
2753
row.id !== memberOrganization.id &&
28-
row.source === OrganizationSource.EMAIL_DOMAIN &&
54+
isCollapsibleMemberOrganization(row) &&
2955
memberOrganizationsOverlap(row, memberOrganization),
3056
)
3157
}
3258

33-
/**
34-
* Groups overlapping email-domain rows into the best non-email-domain display row.
35-
*/
59+
function canDisplayCollapsibleRow<T extends IMemberOrganization>(
60+
displayRow: T,
61+
collapsibleRow: T,
62+
): boolean {
63+
if (!isCollapsibleMemberOrganization(displayRow)) {
64+
return true
65+
}
66+
67+
return (
68+
getMemberOrganizationSourceRank(displayRow.source) <
69+
getMemberOrganizationSourceRank(collapsibleRow.source)
70+
)
71+
}
72+
73+
/** Collapse overlapping email-domain and project-registry rows into one work experience for display. */
3674
export function groupMemberOrganizations<T extends IMemberOrganization>(rows: T[]): T[] {
37-
const emailDomainRows = rows.filter((row) => row.source === OrganizationSource.EMAIL_DOMAIN)
38-
const nonEmailRows = rows.filter((row) => row.source !== OrganizationSource.EMAIL_DOMAIN)
39-
const hiddenEmailDomainIds = new Set<string>()
40-
const displayGroups = new Map<string, { displayRow: T; groupedEmailDomainRows: T[] }>()
41-
42-
for (const emailDomainRow of emailDomainRows.filter(
43-
(row): row is T & { id: string } => !!row.id,
44-
)) {
45-
const overlappingNonEmailRows = nonEmailRows.filter((row) =>
46-
memberOrganizationsOverlap(emailDomainRow, row),
75+
const collapsibleRows = rows.filter(
76+
(row): row is T & { id: string } => !!row.id && isCollapsibleMemberOrganization(row),
77+
)
78+
const hiddenCollapsibleIds = new Set<string>()
79+
const collapsibleParentDisplayId = new Map<string, string>()
80+
const displayGroups = new Map<string, { displayRow: T & { id: string }; groupedRows: T[] }>()
81+
82+
for (const collapsibleRow of collapsibleRows) {
83+
const overlappingDisplayRows = rows.filter(
84+
(row): row is T & { id: string } =>
85+
!!row.id &&
86+
row.id !== collapsibleRow.id &&
87+
memberOrganizationsOverlap(collapsibleRow, row) &&
88+
canDisplayCollapsibleRow(row, collapsibleRow),
4789
)
4890

49-
if (overlappingNonEmailRows.length > 0) {
50-
const displayRow = [...overlappingNonEmailRows].sort((a, b) => {
51-
const rankDiff =
52-
getMemberOrganizationSourceRank(a.source) - getMemberOrganizationSourceRank(b.source)
53-
if (rankDiff !== 0) {
54-
return rankDiff
55-
}
91+
if (overlappingDisplayRows.length > 0) {
92+
const displayRow = [...overlappingDisplayRows].sort(compareMemberOrganizationsBySourceRank)[0]
93+
hiddenCollapsibleIds.add(collapsibleRow.id)
94+
collapsibleParentDisplayId.set(collapsibleRow.id, displayRow.id)
95+
}
96+
}
5697

57-
return (a.id ?? '').localeCompare(b.id ?? '')
58-
})[0]
98+
const resolveDisplayRowId = (collapsibleRowId: string): string => {
99+
let displayRowId = collapsibleRowId
100+
while (collapsibleParentDisplayId.has(displayRowId)) {
101+
displayRowId = collapsibleParentDisplayId.get(displayRowId)!
102+
}
103+
return displayRowId
104+
}
59105

60-
if (displayRow.id) {
61-
hiddenEmailDomainIds.add(emailDomainRow.id)
106+
for (const collapsibleRow of collapsibleRows) {
107+
if (hiddenCollapsibleIds.has(collapsibleRow.id)) {
108+
const displayRowId = resolveDisplayRowId(collapsibleRow.id)
109+
const displayRow = rows.find((row): row is T & { id: string } => row.id === displayRowId)
62110

63-
const existingGroup = displayGroups.get(displayRow.id)
111+
if (displayRow) {
112+
const existingGroup = displayGroups.get(displayRowId)
64113
if (existingGroup) {
65-
existingGroup.groupedEmailDomainRows.push(emailDomainRow)
114+
existingGroup.groupedRows.push(collapsibleRow)
66115
} else {
67-
displayGroups.set(displayRow.id, {
116+
displayGroups.set(displayRowId, {
68117
displayRow,
69-
groupedEmailDomainRows: [emailDomainRow],
118+
groupedRows: [collapsibleRow],
70119
})
71120
}
72121
}
73122
}
74123
}
75124

76125
return rows
77-
.filter((row): row is T & { id: string } => !!row.id && !hiddenEmailDomainIds.has(row.id))
126+
.filter((row): row is T & { id: string } => !!row.id && !hiddenCollapsibleIds.has(row.id))
78127
.map((row) => {
79128
const group = displayGroups.get(row.id)
80129
if (!group) {
81130
return row
82131
}
83132

84-
// Preserve the visible row while surfacing the combined sources and date range
85-
const groupedRows = [group.displayRow, ...group.groupedEmailDomainRows]
133+
const groupedRows = [group.displayRow, ...group.groupedRows]
86134

87135
const normalizedStarts = groupedRows
88136
.map((groupedRow) => normalizeMemberOrganizationDate(groupedRow.dateStart))
@@ -96,7 +144,12 @@ export function groupMemberOrganizations<T extends IMemberOrganization>(rows: T[
96144

97145
for (const groupedRow of groupedRows) {
98146
if (groupedRow.source) {
99-
sources.add(groupedRow.source)
147+
for (const source of groupedRow.source.split(',')) {
148+
const trimmed = source.trim()
149+
if (trimmed) {
150+
sources.add(trimmed)
151+
}
152+
}
100153
}
101154
}
102155

services/apps/git_integration/src/crowdgit/database/crud.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -726,10 +726,9 @@ async def fetch_member_organizations(member_ids: list[str]) -> list[dict]:
726726

727727
return await query(
728728
"""
729-
SELECT "memberId", "organizationId", "dateStart", "dateEnd", source
729+
SELECT "memberId", "organizationId", "dateStart", "dateEnd", source, "deletedAt"
730730
FROM "memberOrganizations"
731731
WHERE "memberId" = ANY($1::uuid[])
732-
AND "deletedAt" IS NULL
733732
""",
734733
(member_ids,),
735734
)
@@ -742,11 +741,10 @@ async def fetch_segment_affiliations(member_ids: list[str], segment_id: str) ->
742741

743742
return await query(
744743
"""
745-
SELECT "memberId", "segmentId", "organizationId", "dateStart", "dateEnd", verified
744+
SELECT "memberId", "segmentId", "organizationId", "dateStart", "dateEnd", verified, "deletedAt"
746745
FROM "memberSegmentAffiliations"
747746
WHERE "memberId" = ANY($1::uuid[])
748747
AND "segmentId" = $2::uuid
749-
AND "deletedAt" IS NULL
750748
AND "organizationId" IS NOT NULL
751749
""",
752750
(member_ids, segment_id),

0 commit comments

Comments
 (0)