Skip to content

Commit 6273aaf

Browse files
committed
feat: collapse project-registry work experiences with email-domain
Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
1 parent 5ea7d3a commit 6273aaf

6 files changed

Lines changed: 116 additions & 69 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: 93 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -15,74 +15,125 @@ 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+
[
28+
OrganizationSource.EMAIL_DOMAIN,
29+
OrganizationSource.PROJECT_REGISTRY,
30+
].includes(value as OrganizationSource),
31+
)
32+
}
33+
34+
function compareMemberOrganizationsBySourceRank(
35+
a: IMemberOrganization,
36+
b: IMemberOrganization,
37+
): number {
38+
const rankDiff =
39+
getMemberOrganizationSourceRank(a.source) - getMemberOrganizationSourceRank(b.source)
40+
if (rankDiff !== 0) {
41+
return rankDiff
42+
}
43+
44+
return (a.id ?? '').localeCompare(b.id ?? '')
45+
}
46+
47+
/** Hidden inferential rows that overlap a visible work experience (for delete/update/override). */
48+
export function getOverlappingGroupedMemberOrganizations<T extends IMemberOrganization>(
2249
rows: T[],
2350
memberOrganization: T,
2451
): T[] {
2552
return rows.filter(
2653
(row) =>
2754
row.id !== memberOrganization.id &&
28-
row.source === OrganizationSource.EMAIL_DOMAIN &&
55+
isCollapsibleMemberOrganization(row) &&
2956
memberOrganizationsOverlap(row, memberOrganization),
3057
)
3158
}
3259

33-
/**
34-
* Groups overlapping email-domain rows into the best non-email-domain display row.
35-
*/
60+
function canDisplayCollapsibleRow<T extends IMemberOrganization>(
61+
displayRow: T,
62+
collapsibleRow: T,
63+
): boolean {
64+
if (!isCollapsibleMemberOrganization(displayRow)) {
65+
return true
66+
}
67+
68+
return (
69+
getMemberOrganizationSourceRank(displayRow.source) <
70+
getMemberOrganizationSourceRank(collapsibleRow.source)
71+
)
72+
}
73+
74+
/** Collapse overlapping email-domain and project-registry rows into one work experience for display. */
3675
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),
76+
const collapsibleRows = rows.filter(
77+
(row): row is T & { id: string } => !!row.id && isCollapsibleMemberOrganization(row),
78+
)
79+
const hiddenCollapsibleIds = new Set<string>()
80+
const collapsibleParentDisplayId = new Map<string, string>()
81+
const displayGroups = new Map<string, { displayRow: T & { id: string }; groupedRows: T[] }>()
82+
83+
for (const collapsibleRow of collapsibleRows) {
84+
const overlappingDisplayRows = rows.filter(
85+
(row): row is T & { id: string } =>
86+
!!row.id &&
87+
row.id !== collapsibleRow.id &&
88+
memberOrganizationsOverlap(collapsibleRow, row) &&
89+
canDisplayCollapsibleRow(row, collapsibleRow),
4790
)
4891

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-
}
92+
if (overlappingDisplayRows.length > 0) {
93+
const displayRow = [...overlappingDisplayRows].sort(compareMemberOrganizationsBySourceRank)[0]
94+
hiddenCollapsibleIds.add(collapsibleRow.id)
95+
collapsibleParentDisplayId.set(collapsibleRow.id, displayRow.id)
96+
}
97+
}
5698

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

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

63-
const existingGroup = displayGroups.get(displayRow.id)
114+
if (displayRow) {
115+
const existingGroup = displayGroups.get(displayRowId)
64116
if (existingGroup) {
65-
existingGroup.groupedEmailDomainRows.push(emailDomainRow)
117+
existingGroup.groupedRows.push(collapsibleRow)
66118
} else {
67-
displayGroups.set(displayRow.id, {
119+
displayGroups.set(displayRowId, {
68120
displayRow,
69-
groupedEmailDomainRows: [emailDomainRow],
121+
groupedRows: [collapsibleRow],
70122
})
71123
}
72124
}
73125
}
74126
}
75127

76128
return rows
77-
.filter((row): row is T & { id: string } => !!row.id && !hiddenEmailDomainIds.has(row.id))
129+
.filter((row): row is T & { id: string } => !!row.id && !hiddenCollapsibleIds.has(row.id))
78130
.map((row) => {
79131
const group = displayGroups.get(row.id)
80132
if (!group) {
81133
return row
82134
}
83135

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

87138
const normalizedStarts = groupedRows
88139
.map((groupedRow) => normalizeMemberOrganizationDate(groupedRow.dateStart))
@@ -96,7 +147,12 @@ export function groupMemberOrganizations<T extends IMemberOrganization>(rows: T[
96147

97148
for (const groupedRow of groupedRows) {
98149
if (groupedRow.source) {
99-
sources.add(groupedRow.source)
150+
for (const source of groupedRow.source.split(',')) {
151+
const trimmed = source.trim()
152+
if (trimmed) {
153+
sources.add(trimmed)
154+
}
155+
}
100156
}
101157
}
102158

0 commit comments

Comments
 (0)