-
Notifications
You must be signed in to change notification settings - Fork 728
chore: implemented patch HTTP method for member project affiliations (CM-1041) #3912
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,104 @@ | ||||||||||||||||
| import type { Request, Response } from 'express' | ||||||||||||||||
| import { z } from 'zod' | ||||||||||||||||
|
|
||||||||||||||||
| import { captureApiChange, memberEditAffiliationsAction } from '@crowd/audit-logs' | ||||||||||||||||
| import { NotFoundError } from '@crowd/common' | ||||||||||||||||
| import { CommonMemberService } from '@crowd/common_services' | ||||||||||||||||
| import { | ||||||||||||||||
| MemberField, | ||||||||||||||||
| fetchMemberSegmentAffiliationForProject, | ||||||||||||||||
| findMemberById, | ||||||||||||||||
| optionsQx, | ||||||||||||||||
| updateMemberSegmentAffiliation, | ||||||||||||||||
| } from '@crowd/data-access-layer' | ||||||||||||||||
|
|
||||||||||||||||
| import { ok } from '@/utils/api' | ||||||||||||||||
| import { validateOrThrow } from '@/utils/validation' | ||||||||||||||||
|
|
||||||||||||||||
| const paramsSchema = z.object({ | ||||||||||||||||
| memberId: z.uuid(), | ||||||||||||||||
| projectId: z.uuid(), | ||||||||||||||||
| }) | ||||||||||||||||
|
|
||||||||||||||||
| const bodySchema = z | ||||||||||||||||
| .object({ | ||||||||||||||||
| organizationId: z.uuid().optional(), | ||||||||||||||||
| dateStart: z.coerce.date().nullable().optional(), | ||||||||||||||||
| dateEnd: z.coerce.date().nullable().optional(), | ||||||||||||||||
| verified: z.boolean().optional(), | ||||||||||||||||
| verifiedBy: z.string().nullable().optional(), | ||||||||||||||||
| }) | ||||||||||||||||
| .refine((data) => Object.keys(data).length > 0, { | ||||||||||||||||
| message: 'At least one field must be provided', | ||||||||||||||||
| }) | ||||||||||||||||
| .refine( | ||||||||||||||||
| (data) => { | ||||||||||||||||
| const { dateStart, dateEnd } = data | ||||||||||||||||
| if (dateStart != null && dateEnd != null) { | ||||||||||||||||
| return dateEnd >= dateStart | ||||||||||||||||
| } | ||||||||||||||||
| return true | ||||||||||||||||
| }, | ||||||||||||||||
| { message: 'dateEnd must be greater than or equal to dateStart' }, | ||||||||||||||||
| ) | ||||||||||||||||
|
|
||||||||||||||||
| export async function patchProjectAffiliation(req: Request, res: Response): Promise<void> { | ||||||||||||||||
| const { memberId, projectId } = validateOrThrow(paramsSchema, req.params) | ||||||||||||||||
| const data = validateOrThrow(bodySchema, req.body) | ||||||||||||||||
|
|
||||||||||||||||
| const qx = optionsQx(req) | ||||||||||||||||
|
|
||||||||||||||||
| const member = await findMemberById(qx, memberId, [MemberField.ID]) | ||||||||||||||||
|
|
||||||||||||||||
| if (!member) { | ||||||||||||||||
| throw new NotFoundError('Member not found') | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| const existing = await fetchMemberSegmentAffiliationForProject(qx, memberId, projectId) | ||||||||||||||||
|
|
||||||||||||||||
| if (!existing) { | ||||||||||||||||
| throw new NotFoundError('Project affiliation not found') | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| let updated = existing | ||||||||||||||||
|
|
||||||||||||||||
| await captureApiChange( | ||||||||||||||||
| req, | ||||||||||||||||
| memberEditAffiliationsAction(memberId, async (captureOldState, captureNewState) => { | ||||||||||||||||
| captureOldState(existing) | ||||||||||||||||
|
|
||||||||||||||||
| await qx.tx(async (tx) => { | ||||||||||||||||
| await updateMemberSegmentAffiliation(tx, memberId, projectId, { | ||||||||||||||||
| ...(data.organizationId !== undefined && { organizationId: data.organizationId }), | ||||||||||||||||
| ...(data.dateStart !== undefined && { dateStart: data.dateStart?.toISOString() ?? null }), | ||||||||||||||||
| ...(data.dateEnd !== undefined && { dateEnd: data.dateEnd?.toISOString() ?? null }), | ||||||||||||||||
| ...(data.verified !== undefined && { verified: data.verified }), | ||||||||||||||||
| ...(data.verifiedBy !== undefined && { verifiedBy: data.verifiedBy }), | ||||||||||||||||
| }) | ||||||||||||||||
|
|
||||||||||||||||
| const organizationId = data.organizationId ?? existing.organizationId | ||||||||||||||||
| if (organizationId) { | ||||||||||||||||
| const service = new CommonMemberService(tx, req.temporal, req.log) | ||||||||||||||||
| await service.startAffiliationRecalculation(memberId, [organizationId]) | ||||||||||||||||
| } | ||||||||||||||||
|
Comment on lines
+80
to
+83
|
||||||||||||||||
| if (organizationId) { | |
| const service = new CommonMemberService(tx, req.temporal, req.log) | |
| await service.startAffiliationRecalculation(memberId, [organizationId]) | |
| } | |
| const organizationIds = organizationId ? [organizationId] : [] | |
| const service = new CommonMemberService(tx, req.temporal, req.log) | |
| await service.startAffiliationRecalculation(memberId, organizationIds) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -11,8 +11,8 @@ export interface IProjectAffiliationSegment { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export interface ISegmentAffiliationWithOrg { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| id: string | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| segmentId: string | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| organizationId: string | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| organizationName: string | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| organizationId: string | null | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| organizationName: string | null | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| organizationLogo: string | null | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| verified: boolean | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
11
to
17
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| verifiedBy: string | null | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -88,6 +88,91 @@ export async function fetchMemberSegmentAffiliationsWithOrg( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Fetch a single segment affiliation for a member + project (segment) combination. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export async function fetchMemberSegmentAffiliationForProject( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| qx: QueryExecutor, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| memberId: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| segmentId: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): Promise<ISegmentAffiliationWithOrg | null> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const rows = await qx.select( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| SELECT | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| msa.id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| msa."segmentId", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| msa."organizationId", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| o."displayName" AS "organizationName", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| o.logo AS "organizationLogo", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| msa.verified, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| msa."verifiedBy", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| msa."dateStart", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| msa."dateEnd" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| FROM "memberSegmentAffiliations" msa | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| LEFT JOIN organizations o ON msa."organizationId" = o.id | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| WHERE msa."memberId" = $(memberId) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| AND msa."segmentId" = $(segmentId) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { memberId, segmentId }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return rows[0] ?? null | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+94
to
+118
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export interface ISegmentAffiliationUpdate { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| organizationId?: string | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| dateStart?: string | null | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| dateEnd?: string | null | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| verified?: boolean | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| verifiedBy?: string | null | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+121
to
+127
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Partially update a member's segment affiliation for a given project (segment). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Only fields present in `data` are updated. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export async function updateMemberSegmentAffiliation( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| qx: QueryExecutor, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| memberId: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| segmentId: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| data: ISegmentAffiliationUpdate, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): Promise<void> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const sets: string[] = [] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const params: Record<string, unknown> = { memberId, segmentId } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ('organizationId' in data) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sets.push('"organizationId" = $(organizationId)') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| params.organizationId = data.organizationId | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ('dateStart' in data) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sets.push('"dateStart" = $(dateStart)') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| params.dateStart = data.dateStart | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ('dateEnd' in data) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sets.push('"dateEnd" = $(dateEnd)') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| params.dateEnd = data.dateEnd | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ('verified' in data) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sets.push('"verified" = $(verified)') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| params.verified = data.verified | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ('verifiedBy' in data) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+142
to
+158
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ('organizationId' in data) { | |
| sets.push('"organizationId" = $(organizationId)') | |
| params.organizationId = data.organizationId | |
| } | |
| if ('dateStart' in data) { | |
| sets.push('"dateStart" = $(dateStart)') | |
| params.dateStart = data.dateStart | |
| } | |
| if ('dateEnd' in data) { | |
| sets.push('"dateEnd" = $(dateEnd)') | |
| params.dateEnd = data.dateEnd | |
| } | |
| if ('verified' in data) { | |
| sets.push('"verified" = $(verified)') | |
| params.verified = data.verified | |
| } | |
| if ('verifiedBy' in data) { | |
| if (data.organizationId !== undefined) { | |
| sets.push('"organizationId" = $(organizationId)') | |
| params.organizationId = data.organizationId | |
| } | |
| if (data.dateStart !== undefined) { | |
| sets.push('"dateStart" = $(dateStart)') | |
| params.dateStart = data.dateStart | |
| } | |
| if (data.dateEnd !== undefined) { | |
| sets.push('"dateEnd" = $(dateEnd)') | |
| params.dateEnd = data.dateEnd | |
| } | |
| if (data.verified !== undefined) { | |
| sets.push('"verified" = $(verified)') | |
| params.verified = data.verified | |
| } | |
| if (data.verifiedBy !== undefined) { |
Copilot
AI
Mar 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
updateMemberSegmentAffiliation updates rows by (memberId, segmentId). If there are multiple memberSegmentAffiliations rows for the same segment (which is possible after the unique constraint was dropped for date ranges), this UPDATE will modify all of them. If the intent is to patch a single affiliation, update by primary key id (and make the API accept that id). If the intent is bulk-update, the API/response should reflect that (and you should return/verify affected row count).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This endpoint treats
(memberId, projectId/segmentId)as identifying a single affiliation, but the DB model supports multiplememberSegmentAffiliationsrows per segment (date ranges removed the unique constraint). As written, the code will update potentially multiple rows and then re-fetch an arbitrary one to return. Consider changing the route to patch by affiliationid(or adding enough filters to uniquely identify a record) and making the DAL update/fetch operate on that id.