Skip to content
Open
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 @@ -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
49 changes: 38 additions & 11 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 @@ -166,12 +168,18 @@ export default class MemberOrganizationsService extends LoggerBase {

try {
const qx = SequelizeRepository.getQueryExecutor(repositoryOptions)
const dates = sanitizeMemberOrganizationDateRange(data.dateStart, data.dateEnd, true)
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 @@ -213,22 +221,41 @@ 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(
{
organizationId: data.organizationId,
title: data.title,
dateStart: data.dateStart,
dateEnd: data.dateEnd,
dateStart: hasDateStart ? targetDateRange.dateStart : undefined,
dateEnd: hasDateEnd ? targetDateRange.dateEnd : undefined,
source: data.source,
verified: data.verified,
verifiedBy: data.verifiedBy,
}).filter(([, v]) => v !== undefined),
)
},
(v) => v !== undefined,
) as MemberOrganizationUpdate

await cleanSoftDeletedMemberOrganization(qx, memberId, data.organizationId, data)
await cleanSoftDeletedMemberOrganization(qx, memberId, data.organizationId, update)
await updateMemberOrganization(qx, memberId, id, update)

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 ? String(dates.dateStart) : null,
endDate: dates.dateEnd ? String(dates.dateEnd) : null,
}
})
}
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
47 changes: 46 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 @@ -88,3 +92,44 @@ 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 = (d: MemberOrganizationDateInput) =>
d === undefined || d === null || d === '' ? null : d

const s = normalize(dateStart)
const e = 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 (e && !s) {
return handleError('Member organization with dateEnd and without dateStart!')
}

if (s && e) {
const startTime = new Date(s).getTime()
const endTime = new Date(e).getTime()

if (isNaN(startTime) || isNaN(endTime)) {
return handleError('Invalid member organization date format!')
}

if (endTime < startTime) {
return handleError('Member organization with dateEnd before dateStart!')
}
}

return { dateStart: s, dateEnd: e }
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getLongestDateRange,
mergeObjects,
safeObjectMerge,
sanitizeMemberOrganizationDateRange,
} from '@crowd/common'
import {
MEMBER_MERGE_FIELDS,
Expand Down Expand Up @@ -113,23 +114,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 @@ -139,8 +141,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 @@ -42,6 +42,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 fetchOrganizationMemberIds(
qx: QueryExecutor,
organizationId: 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
8 changes: 6 additions & 2 deletions services/libs/types/src/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ export interface IOrganization {
export interface IMemberOrganization {
id?: string
title?: string
dateStart: Date | string
dateEnd: Date | string
dateStart: Date | string | null
dateEnd: Date | string | null
memberId: string
organizationId: string
updatedAt?: string
Expand All @@ -66,6 +66,10 @@ export interface IMemberOrganization {
affiliationOverride?: IMemberOrganizationAffiliationOverride
}

export type MemberOrganizationDateRange = Pick<IMemberOrganization, 'dateStart' | 'dateEnd'>

export type MemberOrganizationDateInput = Date | string | null | undefined

type MemberOrganizationEditableFields = Pick<
IMemberOrganization,
| 'organizationId'
Expand Down
Loading