Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import type { Request, Response } from 'express'
import { z } from 'zod'

import { captureApiChange, memberEditOrganizationsAction } from '@crowd/audit-logs'
import { ConflictError, NotFoundError } from '@crowd/common'
import {
BadRequestError,
ConflictError,
NotFoundError,
sanitizeMemberOrganizationDateRange,
} from '@crowd/common'
import { CommonMemberService } from '@crowd/common_services'
import {
MemberField,
Expand All @@ -14,7 +19,11 @@ import {
findMemberById,
optionsQx,
} from '@crowd/data-access-layer'
import type { IMemberOrganization, IMemberRoleWithOrganization } from '@crowd/types'
import type {
IMemberOrganization,
IMemberRoleWithOrganization,
MemberOrganizationDateRange,
} from '@crowd/types'

import { created } from '@/utils/api'
import { toMemberWorkExperience } from '@/utils/mapper'
Expand Down Expand Up @@ -53,12 +62,20 @@ export async function createMemberWorkExperience(req: Request, res: Response): P
memberEditOrganizationsAction(memberId, async (captureOldState, captureNewState) => {
captureOldState({})

let dates: MemberOrganizationDateRange

try {
dates = sanitizeMemberOrganizationDateRange(data.startDate, data.endDate, true)
} catch (error) {
throw new BadRequestError('Invalid work experience date range')
}

const memberOrgData: IMemberOrganization = {
memberId,
organizationId: data.organizationId,
title: data.jobTitle,
dateStart: data.startDate,
dateEnd: data.endDate,
dateStart: dates.dateStart,
dateEnd: dates.dateEnd,
source: data.source,
verified: data.verified,
verifiedBy: data.verifiedBy,
Expand All @@ -67,7 +84,7 @@ export async function createMemberWorkExperience(req: Request, res: Response): P
let newMemberOrgId: string | undefined

await qx.tx(async (tx) => {
await cleanSoftDeletedMemberOrganization(tx, memberId, data.organizationId, data)
await cleanSoftDeletedMemberOrganization(tx, memberId, data.organizationId, memberOrgData)

newMemberOrgId = await createMemberOrganization(tx, memberId, memberOrgData)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Request, Response } from 'express'
import { z } from 'zod'

import { captureApiChange, memberEditOrganizationsAction } from '@crowd/audit-logs'
import { NotFoundError } from '@crowd/common'
import { BadRequestError, NotFoundError, sanitizeMemberOrganizationDateRange } from '@crowd/common'
import { CommonMemberService } from '@crowd/common_services'
import {
MemberField,
Expand All @@ -12,7 +12,7 @@ import {
optionsQx,
updateMemberOrganization,
} from '@crowd/data-access-layer'
import type { MemberOrganizationUpdate } from '@crowd/types'
import type { MemberOrganizationDateRange, MemberOrganizationUpdate } from '@crowd/types'

import { ok } from '@/utils/api'
import { toMemberWorkExperience } from '@/utils/mapper'
Expand Down Expand Up @@ -52,14 +52,22 @@ export async function updateMemberWorkExperience(req: Request, res: Response): P
throw new NotFoundError('Work experience not found')
}

let dates: MemberOrganizationDateRange

try {
dates = sanitizeMemberOrganizationDateRange(data.startDate, data.endDate, true)
} catch (error) {
throw new BadRequestError('Invalid work experience date range')
}

const update: MemberOrganizationUpdate = {
organizationId: data.organizationId,
title: data.jobTitle,
verified: data.verified,
verifiedBy: data.verifiedBy,
source: data.source,
dateStart: data.startDate,
dateEnd: data.endDate,
dateStart: dates.dateStart,
dateEnd: dates.dateEnd,
}

let updated: ReturnType<typeof toMemberWorkExperience> | undefined
Expand Down
52 changes: 39 additions & 13 deletions backend/src/services/member/memberOrganizationsService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/* eslint-disable no-continue */
import lodash from 'lodash'
import { Transaction } from 'sequelize'

import { Error404 } from '@crowd/common'
import { Error404, sanitizeMemberOrganizationDateRange } from '@crowd/common'
import { CommonMemberService } from '@crowd/common_services'
import {
OrganizationField,
Expand All @@ -10,6 +11,7 @@ import {
cleanSoftDeletedMemberOrganization,
createMemberOrganization,
deleteMemberOrganizations,
fetchMemberOrganizationById,
fetchMemberOrganizations,
findMemberAffiliationOverrides,
optionsQx,
Expand Down Expand Up @@ -167,12 +169,18 @@ export default class MemberOrganizationsService extends LoggerBase {

try {
const qx = SequelizeRepository.getQueryExecutor(repositoryOptions)
const dates = sanitizeMemberOrganizationDateRange(data.dateStart, data.dateEnd, true)
Comment thread
skwowet marked this conversation as resolved.
const memberOrgData: Partial<IMemberOrganization> = {
...data,
dateStart: dates.dateStart,
dateEnd: dates.dateEnd,
}

// Clean up any soft-deleted entries
await cleanSoftDeletedMemberOrganization(qx, memberId, data.organizationId, data)
await cleanSoftDeletedMemberOrganization(qx, memberId, data.organizationId, memberOrgData)

// Create new member organization
const newMemberOrgId = await createMemberOrganization(qx, memberId, data)
const newMemberOrgId = await createMemberOrganization(qx, memberId, memberOrgData)

// Check if organization affiliation is blocked
const isAffiliationBlocked = await checkOrganizationAffiliationPolicy(qx, data.organizationId)
Expand Down Expand Up @@ -214,26 +222,44 @@ export default class MemberOrganizationsService extends LoggerBase {
try {
const qx = SequelizeRepository.getQueryExecutor(repositoryOptions)

const update: MemberOrganizationUpdate = Object.fromEntries(
Object.entries({
const existing = await fetchMemberOrganizationById(qx, id)
if (!existing || existing.memberId !== memberId) {
throw new Error404(`Member organization with id ${id} not found!`)
}

const hasDateStart = data.dateStart !== undefined
const hasDateEnd = data.dateEnd !== undefined
const targetDateRange = sanitizeMemberOrganizationDateRange(
hasDateStart ? data.dateStart : existing.dateStart,
hasDateEnd ? data.dateEnd : existing.dateEnd,
true,
)
Comment thread
skwowet marked this conversation as resolved.

const update = lodash.pickBy(
Comment thread
skwowet marked this conversation as resolved.
{
organizationId: data.organizationId,
title: data.title,
dateStart: data.dateStart,
dateEnd: data.dateEnd,
dateStart: hasDateStart ? targetDateRange.dateStart : undefined,
dateEnd: hasDateEnd ? targetDateRange.dateEnd : undefined,

verified: data.verified,
verifiedBy: data.verifiedBy,
}).filter(([, v]) => v !== undefined),
)
},
(v) => v !== undefined,
) as MemberOrganizationUpdate

await cleanSoftDeletedMemberOrganization(qx, memberId, data.organizationId, data)
// Any manual edit from the frontend promotes ownership to UI so automated
// sources (e.g. email-domain inference) no longer overwrite user intent.
await cleanSoftDeletedMemberOrganization(qx, memberId, data.organizationId, update)
await updateMemberOrganization(qx, memberId, id, {
...update,
source: OrganizationSource.UI,
})

await this.commonMemberService.startAffiliationRecalculation(memberId, [data.organizationId])
// Trigger recalculation for old and new orgs if changed
const orgsToRecalculate = Array.from(
new Set([existing.organizationId, data.organizationId]),
).filter((orgId): orgId is string => Boolean(orgId))

await this.commonMemberService.startAffiliationRecalculation(memberId, orgsToRecalculate)

const result = await this.list(memberId, transaction)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
generateUUIDv1,
hasIntersection,
replaceDoubleQuotes,
sanitizeMemberOrganizationDateRange,
setAttributesDefaultValues,
} from '@crowd/common'
import { CommonMemberService } from '@crowd/common_services'
Expand Down Expand Up @@ -383,6 +384,10 @@ export async function updateMemberUsingSquashedPayload(

const newOrUpdatedMemberOrgs = []

squashedPayload.memberOrganizations = sanitizeWorkExperienceDateRanges(
squashedPayload.memberOrganizations,
)

if (squashedPayload.memberOrganizations.length > 0) {
const orgPromises = []

Expand Down Expand Up @@ -754,6 +759,20 @@ interface IWorkExperienceChanges {
toUpdate: Map<IMemberOrganizationData, Record<string, any>>
}

function sanitizeWorkExperienceDateRanges(
organizations: IMemberEnrichmentDataNormalizedOrganization[],
): IMemberEnrichmentDataNormalizedOrganization[] {
return organizations.map((org) => {
const dates = sanitizeMemberOrganizationDateRange(org.startDate, org.endDate)

return {
...org,
startDate: dates.dateStart instanceof Date ? dates.dateStart.toISOString() : dates.dateStart,
endDate: dates.dateEnd instanceof Date ? dates.dateEnd.toISOString() : dates.dateEnd,
}
})
}
Comment thread
skwowet marked this conversation as resolved.
Comment thread
skwowet marked this conversation as resolved.

function prepareWorkExperiences(
oldVersion: IMemberOrganizationData[],
newVersion: IMemberEnrichmentDataNormalizedOrganization[],
Expand Down
4 changes: 2 additions & 2 deletions services/apps/members_enrichment_worker/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ export interface IMemberEnrichmentDataNormalizedOrganization {
identities?: IOrganizationIdentity[]
title?: string
organizationDescription?: string
startDate?: string
endDate?: string
startDate?: string | null
endDate?: string | null
source: OrganizationSource
}

Expand Down
45 changes: 44 additions & 1 deletion services/libs/common/src/member.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import merge from 'lodash.merge'
import ldSum from 'lodash.sum'

import { OrganizationSource } from '@crowd/types'
import {
MemberOrganizationDateInput,
MemberOrganizationDateRange,
OrganizationSource,
} from '@crowd/types'

/* eslint-disable @typescript-eslint/no-explicit-any */

Expand Down Expand Up @@ -91,3 +95,42 @@ export function getMemberOrganizationSourceRank(source: string | null | undefine
if (source?.startsWith('enrichment-')) return 2
return 3
}

/**
* Normalizes and validates a member's date range.
* If throwError is true, it throws descriptive errors on failure.
* Otherwise, it returns nulls for invalid ranges.
*/
export function sanitizeMemberOrganizationDateRange(
dateStart: MemberOrganizationDateInput,
dateEnd: MemberOrganizationDateInput,
throwError = false,
): MemberOrganizationDateRange {
const normalize = (date: MemberOrganizationDateInput) =>
date === undefined || date === null || date === '' ? null : date

const start = normalize(dateStart)
const end = normalize(dateEnd)

const handleError = (message: string): MemberOrganizationDateRange => {
if (throwError) throw new Error(message)
return { dateStart: null, dateEnd: null }
}
Comment thread
skwowet marked this conversation as resolved.

if (end && !start) {
return handleError('Member organization with dateEnd and without dateStart!')
}

const startTime = start ? new Date(start).getTime() : null
const endTime = end ? new Date(end).getTime() : null

if ((start && Number.isNaN(startTime)) || (end && Number.isNaN(endTime))) {
return handleError('Invalid member organization date format!')
}

if (startTime !== null && endTime !== null && endTime < startTime) {
return handleError('Member organization with dateEnd before dateStart!')
}
Comment thread
cursor[bot] marked this conversation as resolved.

return { dateStart: start, dateEnd: end }
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
getMemberOrganizationSourceRank,
mergeObjects,
safeObjectMerge,
sanitizeMemberOrganizationDateRange,
} from '@crowd/common'
import {
MEMBER_MERGE_FIELDS,
Expand Down Expand Up @@ -114,23 +115,24 @@ export class CommonMemberService extends LoggerBase {

for (const item of organizations) {
const org = typeof item === 'string' ? { id: item } : item
const dates = sanitizeMemberOrganizationDateRange(org.startDate, org.endDate, true)

// we don't need to touch exactly same existing work experiences
if (
!originalOrgs.some(
(w) =>
w.organizationId === item.id &&
w.title === (item.title || null) &&
w.dateStart === (item.startDate || null) &&
w.dateEnd === (item.endDate || null),
w.organizationId === org.id &&
w.title === (org.title || null) &&
w.dateStart === dates.dateStart &&
w.dateEnd === dates.dateEnd,
)
) {
const newOrg = {
memberId,
organizationId: org.id,
title: org.title,
dateStart: org.startDate,
dateEnd: org.endDate,
dateStart: dates.dateStart,
dateEnd: dates.dateEnd,
source: org.source,
}

Expand All @@ -140,8 +142,8 @@ export class CommonMemberService extends LoggerBase {
org.id,
org.source,
org.title,
org.startDate,
org.endDate,
dates.dateStart as string | null,
dates.dateEnd as string | null,
)

const isAffiliationBlocked = await checkOrganizationAffiliationPolicy(this.qx, org.id)
Expand Down
10 changes: 10 additions & 0 deletions services/libs/data-access-layer/src/members/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ export async function fetchMemberOrganizations(
)
}

export async function fetchMemberOrganizationById(
qx: QueryExecutor,
id: string,
): Promise<IMemberOrganization | undefined> {
return qx.selectOneOrNone(
`SELECT * FROM "memberOrganizations" WHERE "id" = $(id) AND "deletedAt" IS NULL`,
{ id },
)
}

export async function fetchMemberOrganizationsBySource(
qx: QueryExecutor,
memberId: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -548,8 +548,8 @@ export async function insertWorkExperience(
memberId: string,
orgId: string,
title: string,
dateStart: string,
dateEnd: string,
dateStart: string | null,
dateEnd: string | null,
source: OrganizationSource,
): Promise<string | null> {
let conflictCondition = `("memberId", "organizationId", "dateStart", "dateEnd")`
Expand Down
Loading
Loading