Skip to content

chore: implemented patch HTTP method for member project affiliations (CM-1041)#3912

Open
themarolt wants to merge 1 commit intomainfrom
feat/patch-project-affiliations-CM-1041
Open

chore: implemented patch HTTP method for member project affiliations (CM-1041)#3912
themarolt wants to merge 1 commit intomainfrom
feat/patch-project-affiliations-CM-1041

Conversation

@themarolt
Copy link
Contributor

@themarolt themarolt commented Mar 11, 2026

Note

Medium Risk
Adds a new public write endpoint that updates memberSegmentAffiliations and triggers affiliation recalculation/audit logging, so mistakes could impact member-organization attribution and downstream computations. Scope is contained to the project-affiliations area with input validation and not-found checks.

Overview
Adds a new PATCH /:memberId/project-affiliations/:projectId public API to partially update a member’s project affiliation (org, dates, verification fields) with zod validation, not-found handling, and audit-log capture.

Extends the data-access layer to support this flow by making org fields nullable in ISegmentAffiliationWithOrg, adding fetchMemberSegmentAffiliationForProject, and adding updateMemberSegmentAffiliation for partial SQL updates (including LEFT JOIN support when org is missing).

Written by Cursor Bugbot for commit 6db1b26. This will update automatically on new commits. Configure here.

Signed-off-by: Uroš Marolt <uros@marolt.me>
Copilot AI review requested due to automatic review settings March 11, 2026 14:20
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a new public API endpoint to partially update a member’s per-project (segment) affiliation, including DAL helpers for fetching/updating the underlying memberSegmentAffiliations rows and making organization fields nullable.

Changes:

  • Made organizationId / organizationName nullable in ISegmentAffiliationWithOrg.
  • Added DAL helpers to fetch a single member+project affiliation and partially update it.
  • Added PATCH /:memberId/project-affiliations/:projectId public API route + handler.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 7 comments.

File Description
services/libs/data-access-layer/src/members/projectAffiliations.ts Adds single-affiliation fetch + partial update helpers; adjusts affiliation-with-org typing for null orgs.
backend/src/api/public/v1/members/project-affiliations/patchProjectAffiliation.ts New PATCH handler to update project affiliation fields and trigger affiliation recalculation.
backend/src/api/public/v1/members/index.ts Wires the new PATCH route into the public v1 members router.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 11 to 17
export interface ISegmentAffiliationWithOrg {
id: string
segmentId: string
organizationId: string
organizationName: string
organizationId: string | null
organizationName: string | null
organizationLogo: string | null
verified: boolean
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.
Comment on lines +94 to +118
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
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.
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) {
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.
Comment on lines +165 to +171
await qx.result(
`
UPDATE "memberSegmentAffiliations"
SET ${sets.join(', ')}
WHERE "memberId" = $(memberId)
AND "segmentId" = $(segmentId)
`,
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.
Comment on lines +121 to +127
export interface ISegmentAffiliationUpdate {
organizationId?: string
dateStart?: string | null
dateEnd?: string | null
verified?: boolean
verifiedBy?: string | null
}
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.
Comment on lines +57 to +61
const existing = await fetchMemberSegmentAffiliationForProject(qx, memberId, projectId)

if (!existing) {
throw new NotFoundError('Project affiliation not found')
}
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.
Comment on lines +80 to +83
if (organizationId) {
const service = new CommonMemberService(tx, req.temporal, req.log)
await service.startAffiliationRecalculation(memberId, [organizationId])
}
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants