Skip to content

Commit 54e9449

Browse files
authored
fix: return 409 or clear hidden twins on work experience PUT conflicts (#4323)
Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
1 parent 485a47b commit 54e9449

2 files changed

Lines changed: 66 additions & 5 deletions

File tree

backend/src/api/public/v1/members/work-experiences/updateMemberWorkExperience.ts

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,50 @@ import type { Request, Response } from 'express'
22
import { z } from 'zod'
33

44
import { 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'
712
import {
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

1828
import { ok } from '@/utils/api'
1929
import {
2030
getOverlappingGroupedMemberOrganizations,
2131
groupMemberOrganizations,
32+
isCollapsibleMemberOrganization,
2233
toMemberWorkExperience,
2334
} from '@/utils/mapper'
2435
import { 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+
2649
const 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

backend/src/utils/mapper.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ function memberOrganizationsOverlap<T extends IMemberOrganization>(a: T, b: T):
1515
)
1616
}
1717

18-
function isCollapsibleMemberOrganization<T extends IMemberOrganization>(row: T): boolean {
18+
export function isCollapsibleMemberOrganization<T extends IMemberOrganization>(row: T): boolean {
1919
if (!row.source) {
2020
return false
2121
}

0 commit comments

Comments
 (0)