Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type { MemberOrganizationDateRange, MemberOrganizationUpdate } from '@cro

import { ok } from '@/utils/api'
import {
getOverlappingEmailDomainMemberOrganizations,
getOverlappingGroupedMemberOrganizations,
groupMemberOrganizations,
toMemberWorkExperience,
} from '@/utils/mapper'
Expand Down Expand Up @@ -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,
)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { IMemberOrganization, IMemberRoleWithOrganization } from '@crowd/types'

import { ok } from '@/utils/api'
import {
getOverlappingEmailDomainMemberOrganizations,
getOverlappingGroupedMemberOrganizations,
groupMemberOrganizations,
toMemberWorkExperience,
} from '@/utils/mapper'
Expand Down Expand Up @@ -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 }
Expand All @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions backend/src/services/member/memberAffiliationsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down
19 changes: 8 additions & 11 deletions backend/src/services/member/memberOrganizationsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -294,7 +291,7 @@ export default class MemberOrganizationsService extends LoggerBase {

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

const overlappingEmailDomainRows = getOverlappingEmailDomainMemberOrganizations(
const overlappingGroupedRows = getOverlappingGroupedMemberOrganizations(
memberOrganizations,
overlapBasis,
)
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
127 changes: 90 additions & 37 deletions backend/src/utils/mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,74 +15,122 @@ function memberOrganizationsOverlap<T extends IMemberOrganization>(a: T, b: T):
)
}

/**
* Finds email-domain rows that are represented by the same visible work experience.
*/
export function getOverlappingEmailDomainMemberOrganizations<T extends IMemberOrganization>(
function isCollapsibleMemberOrganization<T extends IMemberOrganization>(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<T extends IMemberOrganization>(
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<T extends IMemberOrganization>(
displayRow: T,
collapsibleRow: T,
): boolean {
if (!isCollapsibleMemberOrganization(displayRow)) {
return true
Comment thread
skwowet marked this conversation as resolved.
}

return (
getMemberOrganizationSourceRank(displayRow.source) <
getMemberOrganizationSourceRank(collapsibleRow.source)
)
}

/** Collapse overlapping email-domain and project-registry rows into one work experience for display. */
export function groupMemberOrganizations<T extends IMemberOrganization>(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<string>()
const displayGroups = new Map<string, { displayRow: T; groupedEmailDomainRows: T[] }>()

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<string>()
const collapsibleParentDisplayId = new Map<string, string>()
const displayGroups = new Map<string, { displayRow: T & { id: string }; groupedRows: T[] }>()

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],
})
}
}
}
}

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))
Expand All @@ -96,7 +144,12 @@ export function groupMemberOrganizations<T extends IMemberOrganization>(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)
}
}
}
}

Expand Down
6 changes: 2 additions & 4 deletions services/apps/git_integration/src/crowdgit/database/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,),
)
Expand All @@ -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),
Expand Down
Loading
Loading