@@ -2,27 +2,50 @@ import type { Request, Response } from 'express'
22import { z } from 'zod'
33
44import { captureApiChange , memberEditOrganizationsAction } from '@crowd/audit-logs'
5- import { BadRequestError , NotFoundError , sanitizeMemberOrganizationDateRange } from '@crowd/common'
6- import { signalMemberUpdate } from '@crowd/common_services'
5+ import {
6+ BadRequestError ,
7+ ConflictError ,
8+ NotFoundError ,
9+ sanitizeMemberOrganizationDateRange ,
10+ } from '@crowd/common'
11+ import { normalizeMemberOrganizationDate , signalMemberUpdate } from '@crowd/common_services'
712import {
813 MemberField ,
914 cleanSoftDeletedMemberOrganization ,
15+ deleteMemberOrganizations ,
1016 fetchManyMemberOrgsWithOrgData ,
1117 fetchMemberOrganizations ,
1218 findMemberById ,
1319 optionsQx ,
1420 updateMemberOrganization ,
1521} from '@crowd/data-access-layer'
16- import type { MemberOrganizationDateRange , MemberOrganizationUpdate } from '@crowd/types'
22+ import type {
23+ IMemberOrganization ,
24+ MemberOrganizationDateRange ,
25+ MemberOrganizationUpdate ,
26+ } from '@crowd/types'
1727
1828import { ok } from '@/utils/api'
1929import {
2030 getOverlappingGroupedMemberOrganizations ,
2131 groupMemberOrganizations ,
32+ isCollapsibleMemberOrganization ,
2233 toMemberWorkExperience ,
2334} from '@/utils/mapper'
2435import { validateOrThrow } from '@/utils/validation'
2536
37+ /** Matches the active unique index on memberOrganizations (org + date range). */
38+ function sameUniqueKey (
39+ a : Pick < IMemberOrganization , 'organizationId' | 'dateStart' | 'dateEnd' > ,
40+ b : Pick < IMemberOrganization , 'organizationId' | 'dateStart' | 'dateEnd' > ,
41+ ) : boolean {
42+ return (
43+ a . organizationId === b . organizationId &&
44+ normalizeMemberOrganizationDate ( a . dateStart ) === normalizeMemberOrganizationDate ( b . dateStart ) &&
45+ normalizeMemberOrganizationDate ( a . dateEnd ) === normalizeMemberOrganizationDate ( b . dateEnd )
46+ )
47+ }
48+
2649const paramsSchema = z . object ( {
2750 memberId : z . uuid ( ) ,
2851 workExperienceId : z . uuid ( ) ,
@@ -83,13 +106,51 @@ export async function updateMemberWorkExperience(req: Request, res: Response): P
83106 captureOldState ( existing )
84107
85108 await qx . tx ( async ( tx ) => {
109+ // Avoid unique-index collisions before we UPDATE the visible row.
110+ const conflictingRows = memberOrgs . filter (
111+ ( row ) =>
112+ ! ! row . id &&
113+ row . id !== workExperienceId &&
114+ sameUniqueKey ( row , {
115+ organizationId : data . organizationId ,
116+ dateStart : dates . dateStart ,
117+ dateEnd : dates . dateEnd ,
118+ } ) ,
119+ )
120+
121+ // Conflict if a visible work experience with the same dates already exists. Throw a conflict error.
122+ const conflictingVisibleIds = conflictingRows
123+ . filter ( ( row ) => ! isCollapsibleMemberOrganization ( row ) )
124+ . map ( ( row ) => row . id )
125+ . filter ( ( id ) : id is string => ! ! id )
126+
127+ if ( conflictingVisibleIds . length > 0 ) {
128+ throw new ConflictError ( 'A work experience with the same dates already exists' )
129+ }
130+
131+ // Conflict if a collapsible work experience with the same dates already exists.
132+ // Soft-delete it so the visible update can take that unique key.
133+ const conflictingHiddenIds = conflictingRows
134+ . filter ( ( row ) => isCollapsibleMemberOrganization ( row ) )
135+ . map ( ( row ) => row . id )
136+ . filter ( ( id ) : id is string => ! ! id )
137+
138+ if ( conflictingHiddenIds . length > 0 ) {
139+ await deleteMemberOrganizations ( tx , memberId , conflictingHiddenIds )
140+ }
141+
142+ // Fan-out below should not touch rows we just soft-deleted.
143+ const memberOrgsAfterConflict = memberOrgs . filter (
144+ ( row ) => ! row . id || ! conflictingHiddenIds . includes ( row . id ) ,
145+ )
146+
86147 await cleanSoftDeletedMemberOrganization ( tx , memberId , data . organizationId , update )
87148 await updateMemberOrganization ( tx , memberId , workExperienceId , update )
88149
89150 const overlapBasis = { ...existing , ...update }
90151
91152 const overlappingGroupedRows = getOverlappingGroupedMemberOrganizations (
92- memberOrgs ,
153+ memberOrgsAfterConflict ,
93154 overlapBasis ,
94155 )
95156
0 commit comments