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
7 changes: 7 additions & 0 deletions backend/src/api/public/v1/members/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getMemberIdentities } from './identities/getMemberIdentities'
import { verifyMemberIdentity } from './identities/verifyMemberIdentity'
import { getMemberMaintainerRoles } from './maintainer-roles/getMemberMaintainerRoles'
import { getProjectAffiliations } from './project-affiliations/getProjectAffiliations'
import { patchProjectAffiliation } from './project-affiliations/patchProjectAffiliation'
import { resolveMemberByIdentities } from './resolveMember'
import { createMemberWorkExperience } from './work-experiences/createMemberWorkExperience'
import { deleteMemberWorkExperience } from './work-experiences/deleteMemberWorkExperience'
Expand Down Expand Up @@ -44,6 +45,12 @@ export function membersRouter(): Router {
safeWrap(getProjectAffiliations),
)

router.patch(
'/:memberId/project-affiliations/:projectId',
requireScopes([SCOPES.WRITE_PROJECT_AFFILIATIONS]),
safeWrap(patchProjectAffiliation),
)

router.post(
'/:memberId/work-experiences',
requireScopes([SCOPES.WRITE_WORK_EXPERIENCES]),
Expand Down
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')
}
Comment on lines +57 to +61
Copy link

Copilot AI Mar 11, 2026

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 multiple memberSegmentAffiliations rows 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 affiliation id (or adding enough filters to uniquely identify a record) and making the DAL update/fetch operate on that id.

Copilot uses AI. Check for mistakes.

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
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Affiliation recalculation is only started when organizationId is truthy. If an existing project affiliation has organizationId = null (supported by the schema), patches to dateStart/dateEnd/verified/verifiedBy will not trigger recalculation and activity affiliations can stay stale. Since the workflow recalculates by memberId anyway, consider starting recalculation unconditionally after a successful update (you can pass an empty organizationIds array when there is no org).

Suggested change
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)

Copilot uses AI. Check for mistakes.
})

updated = await fetchMemberSegmentAffiliationForProject(qx, memberId, projectId)
if (!updated) {
throw new Error('Failed to re-fetch project affiliation after update')
}
captureNewState(updated)
}),
)

ok(res, {
id: updated.id,
organizationId: updated.organizationId,
organizationName: updated.organizationName,
organizationLogo: updated.organizationLogo ?? null,
verified: updated.verified,
verifiedBy: updated.verifiedBy ?? null,
startDate: updated.dateStart ?? null,
endDate: updated.dateEnd ?? null,
})
}
89 changes: 87 additions & 2 deletions services/libs/data-access-layer/src/members/projectAffiliations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ISegmentAffiliationWithOrg now allows organizationId/organizationName to be null, but fetchMemberSegmentAffiliationsWithOrg() still does an INNER JOIN on organizations, which will silently drop affiliations where organizationId is NULL (or the org row is missing). That can cause getProjectAffiliations to incorrectly fall back to work experiences even when a manual segment affiliation exists. Consider switching that query to LEFT JOIN organizations (like the single-fetch does) so null-org overrides are returned consistently.

Copilot uses AI. Check for mistakes.
verifiedBy: string | null
Expand Down Expand Up @@ -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
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchMemberSegmentAffiliationForProject returns rows[0] without any ORDER BY/LIMIT. Since the unique constraint on (memberId, segmentId) was dropped when date ranges were introduced (see migration V1691658076), there can be multiple affiliations per member+segment. This function can therefore return an arbitrary row depending on query plan. Either reintroduce uniqueness, or make the selection deterministic (ORDER BY + LIMIT 1) and/or fetch by affiliation id instead of segmentId.

Copilot uses AI. Check for mistakes.
}

export interface ISegmentAffiliationUpdate {
organizationId?: string
dateStart?: string | null
dateEnd?: string | null
verified?: boolean
verifiedBy?: string | null
}
Comment on lines +121 to +127
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ISegmentAffiliationUpdate.organizationId is typed as string (not nullable), but the underlying column is nullable and ISegmentAffiliationWithOrg.organizationId is now string | null. If clearing an affiliation is a supported operation, organizationId should allow null here so callers can explicitly unset it (and the API schema should accept null accordingly).

Copilot uses AI. Check for mistakes.

/**
* 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
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The partial-update builder uses 'field' in data checks. If a caller passes an object with an explicit undefined value (e.g. { verifiedBy: undefined }), pg-promise formatting will receive an undefined parameter and can throw at runtime. Prefer checking data.field !== undefined for each optional field, and use null explicitly when the intention is to clear a column.

Suggested change
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 uses AI. Check for mistakes.
sets.push('"verifiedBy" = $(verifiedBy)')
params.verifiedBy = data.verifiedBy
}

if (sets.length === 0) return

await qx.result(
`
UPDATE "memberSegmentAffiliations"
SET ${sets.join(', ')}
WHERE "memberId" = $(memberId)
AND "segmentId" = $(segmentId)
`,
Comment on lines +165 to +171
Copy link

Copilot AI Mar 11, 2026

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).

Copilot uses AI. Check for mistakes.
params,
)
}

/**
* Fetch work experiences for a member with organization details.
* Used as fallback affiliations when no segment affiliations exist for a project.
Expand Down
Loading