diff --git a/backend/src/api/public/v1/members/work-experiences/createMemberWorkExperience.ts b/backend/src/api/public/v1/members/work-experiences/createMemberWorkExperience.ts index 1e8492799c..03f424bdf3 100644 --- a/backend/src/api/public/v1/members/work-experiences/createMemberWorkExperience.ts +++ b/backend/src/api/public/v1/members/work-experiences/createMemberWorkExperience.ts @@ -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, @@ -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' @@ -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, @@ -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) diff --git a/backend/src/api/public/v1/members/work-experiences/updateMemberWorkExperience.ts b/backend/src/api/public/v1/members/work-experiences/updateMemberWorkExperience.ts index f0a58bbec5..60cc0ff28b 100644 --- a/backend/src/api/public/v1/members/work-experiences/updateMemberWorkExperience.ts +++ b/backend/src/api/public/v1/members/work-experiences/updateMemberWorkExperience.ts @@ -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, @@ -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' @@ -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 | undefined diff --git a/backend/src/services/member/memberOrganizationsService.ts b/backend/src/services/member/memberOrganizationsService.ts index 251ea59019..dbe129bb14 100644 --- a/backend/src/services/member/memberOrganizationsService.ts +++ b/backend/src/services/member/memberOrganizationsService.ts @@ -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, @@ -10,6 +11,7 @@ import { cleanSoftDeletedMemberOrganization, createMemberOrganization, deleteMemberOrganizations, + fetchMemberOrganizationById, fetchMemberOrganizations, findMemberAffiliationOverrides, optionsQx, @@ -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 = { + ...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) @@ -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, + ) + + 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) diff --git a/services/apps/members_enrichment_worker/src/activities/enrichment.ts b/services/apps/members_enrichment_worker/src/activities/enrichment.ts index df4146452f..7de5deeb10 100644 --- a/services/apps/members_enrichment_worker/src/activities/enrichment.ts +++ b/services/apps/members_enrichment_worker/src/activities/enrichment.ts @@ -6,6 +6,7 @@ import { generateUUIDv1, hasIntersection, replaceDoubleQuotes, + sanitizeMemberOrganizationDateRange, setAttributesDefaultValues, } from '@crowd/common' import { CommonMemberService } from '@crowd/common_services' @@ -383,6 +384,10 @@ export async function updateMemberUsingSquashedPayload( const newOrUpdatedMemberOrgs = [] + squashedPayload.memberOrganizations = sanitizeWorkExperienceDateRanges( + squashedPayload.memberOrganizations, + ) + if (squashedPayload.memberOrganizations.length > 0) { const orgPromises = [] @@ -754,6 +759,20 @@ interface IWorkExperienceChanges { toUpdate: Map> } +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, + } + }) +} + function prepareWorkExperiences( oldVersion: IMemberOrganizationData[], newVersion: IMemberEnrichmentDataNormalizedOrganization[], diff --git a/services/apps/members_enrichment_worker/src/types.ts b/services/apps/members_enrichment_worker/src/types.ts index 8b47e7d471..cc72c89519 100644 --- a/services/apps/members_enrichment_worker/src/types.ts +++ b/services/apps/members_enrichment_worker/src/types.ts @@ -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 } diff --git a/services/libs/common/src/member.ts b/services/libs/common/src/member.ts index 2ca0d2c455..a7953afa24 100644 --- a/services/libs/common/src/member.ts +++ b/services/libs/common/src/member.ts @@ -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 */ @@ -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 } + } + + 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 } +} diff --git a/services/libs/common_services/src/services/common.member.service.ts b/services/libs/common_services/src/services/common.member.service.ts index 70c70e0647..e187c04027 100644 --- a/services/libs/common_services/src/services/common.member.service.ts +++ b/services/libs/common_services/src/services/common.member.service.ts @@ -15,6 +15,7 @@ import { getLongestDateRange, mergeObjects, safeObjectMerge, + sanitizeMemberOrganizationDateRange, } from '@crowd/common' import { MEMBER_MERGE_FIELDS, @@ -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, } @@ -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) diff --git a/services/libs/data-access-layer/src/members/organizations.ts b/services/libs/data-access-layer/src/members/organizations.ts index 205c871a82..9ed8d8a00f 100644 --- a/services/libs/data-access-layer/src/members/organizations.ts +++ b/services/libs/data-access-layer/src/members/organizations.ts @@ -42,6 +42,16 @@ export async function fetchMemberOrganizations( ) } +export async function fetchMemberOrganizationById( + qx: QueryExecutor, + id: string, +): Promise { + return qx.selectOneOrNone( + `SELECT * FROM "memberOrganizations" WHERE "id" = $(id) AND "deletedAt" IS NULL`, + { id }, + ) +} + export async function fetchOrganizationMemberIds( qx: QueryExecutor, organizationId: string, diff --git a/services/libs/data-access-layer/src/old/apps/members_enrichment_worker/index.ts b/services/libs/data-access-layer/src/old/apps/members_enrichment_worker/index.ts index 4bcb7d096c..1897c2932f 100644 --- a/services/libs/data-access-layer/src/old/apps/members_enrichment_worker/index.ts +++ b/services/libs/data-access-layer/src/old/apps/members_enrichment_worker/index.ts @@ -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 { let conflictCondition = `("memberId", "organizationId", "dateStart", "dateEnd")` diff --git a/services/libs/types/src/organizations.ts b/services/libs/types/src/organizations.ts index d5b01d6beb..7f04709fe7 100644 --- a/services/libs/types/src/organizations.ts +++ b/services/libs/types/src/organizations.ts @@ -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 @@ -66,6 +66,10 @@ export interface IMemberOrganization { affiliationOverride?: IMemberOrganizationAffiliationOverride } +export type MemberOrganizationDateRange = Pick + +export type MemberOrganizationDateInput = Date | string | null | undefined + type MemberOrganizationEditableFields = Pick< IMemberOrganization, | 'organizationId'