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
Expand Up @@ -11,45 +11,16 @@ import {
findMemberById,
optionsQx,
} from '@crowd/data-access-layer'
import type {
ISegmentAffiliationWithOrg,
IWorkExperienceAffiliation,
} from '@crowd/data-access-layer'

import { ok } from '@/utils/api'
import { validateOrThrow } from '@/utils/validation'

import { mapSegmentAffiliation, mapWorkExperienceAffiliation } from './mappers'

const paramsSchema = z.object({
memberId: z.uuid(),
})

function mapSegmentAffiliation(a: ISegmentAffiliationWithOrg) {
return {
id: a.id,
organizationId: a.organizationId,
organizationName: a.organizationName,
organizationLogo: a.organizationLogo ?? null,
verified: a.verified,
verifiedBy: a.verifiedBy ?? null,
startDate: a.dateStart ?? null,
endDate: a.dateEnd ?? null,
}
}

function mapWorkExperienceAffiliation(a: IWorkExperienceAffiliation) {
return {
id: a.id,
organizationId: a.organizationId,
organizationName: a.organizationName,
organizationLogo: a.organizationLogo ?? null,
verified: a.verified ?? false,
verifiedBy: a.verifiedBy ?? null,
source: a.source ?? null,
startDate: a.dateStart ?? null,
endDate: a.dateEnd ?? null,
}
}

export async function getProjectAffiliations(req: Request, res: Response): Promise<void> {
const { memberId } = validateOrThrow(paramsSchema, req.params)
const qx = optionsQx(req)
Expand Down
31 changes: 31 additions & 0 deletions backend/src/api/public/v1/members/project-affiliations/mappers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type {
ISegmentAffiliationWithOrg,
IWorkExperienceAffiliation,
} from '@crowd/data-access-layer'

export function mapSegmentAffiliation(a: ISegmentAffiliationWithOrg) {
return {
id: a.id,
organizationId: a.organizationId,
organizationName: a.organizationName,
organizationLogo: a.organizationLogo ?? null,
verified: a.verified,
verifiedBy: a.verifiedBy ?? null,
startDate: a.dateStart ?? null,
endDate: a.dateEnd ?? null,
}
}

export function mapWorkExperienceAffiliation(a: IWorkExperienceAffiliation) {
return {
id: a.id,
organizationId: a.organizationId,
organizationName: a.organizationName,
organizationLogo: a.organizationLogo ?? null,
verified: a.verified ?? false,
verifiedBy: a.verifiedBy ?? null,
source: a.source ?? null,
startDate: a.dateStart ?? null,
endDate: a.dateEnd ?? null,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
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,
deleteAllMemberSegmentAffiliationsForProject,
fetchMemberProjectSegment,
fetchMemberSegmentAffiliationsForProject,
fetchMemberWorkExperienceAffiliations,
findMaintainerRoles,
findMemberById,
insertMemberSegmentAffiliations,
optionsQx,
} from '@crowd/data-access-layer'

import { ok } from '@/utils/api'
import { validateOrThrow } from '@/utils/validation'

import { mapSegmentAffiliation, mapWorkExperienceAffiliation } from './mappers'

const paramsSchema = z.object({
memberId: z.uuid(),
projectId: z.uuid(),
})

const bodySchema = z.object({
affiliations: z
.array(
z
.object({
organizationId: z.uuid(),
dateStart: z.coerce.date(),
dateEnd: z.coerce.date().nullable().optional(),
})
.refine((a) => a.dateEnd == null || a.dateEnd >= a.dateStart, {
message: 'dateEnd must be greater than or equal to dateStart',
}),
)
.min(1),
verifiedBy: z.string().max(255),
})

export async function patchProjectAffiliation(req: Request, res: Response): Promise<void> {
const { memberId, projectId } = validateOrThrow(paramsSchema, req.params)
const { affiliations, verifiedBy } = 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 segment = await fetchMemberProjectSegment(qx, memberId, projectId)
if (!segment) {
throw new NotFoundError('Project not found')
}

const existingAffiliations = await fetchMemberSegmentAffiliationsForProject(
qx,
memberId,
projectId,
)

await captureApiChange(
req,
memberEditAffiliationsAction(memberId, async (captureOldState, captureNewState) => {
captureOldState(existingAffiliations)

await qx.tx(async (tx) => {
await deleteAllMemberSegmentAffiliationsForProject(tx, memberId, projectId)

await insertMemberSegmentAffiliations(
tx,
memberId,
projectId,
affiliations.map((a) => ({
organizationId: a.organizationId,
dateStart: a.dateStart.toISOString(),
dateEnd: a.dateEnd?.toISOString() ?? null,
verifiedBy,
})),
)

const oldOrgIds = existingAffiliations.map((a) => a.organizationId)
const newOrgIds = affiliations.map((a) => a.organizationId)
const orgIdsToRecalculate = [...new Set([...oldOrgIds, ...newOrgIds])]

const service = new CommonMemberService(tx, req.temporal, req.log)
await service.startAffiliationRecalculation(memberId, orgIdsToRecalculate)
})

const updatedAffiliations = await fetchMemberSegmentAffiliationsForProject(
qx,
memberId,
projectId,
)
captureNewState(updatedAffiliations)
}),
)

const [updatedAffiliations, maintainerRoles, workExperiences] = await Promise.all([
fetchMemberSegmentAffiliationsForProject(qx, memberId, projectId),
findMaintainerRoles(qx, [memberId]),
fetchMemberWorkExperienceAffiliations(qx, memberId),
])

const roles = maintainerRoles
.filter((r) => r.segmentId === projectId)
.map((r) => ({
id: r.id,
role: r.role,
startDate: r.dateStart ?? null,
endDate: r.dateEnd ?? null,
repoUrl: r.url ?? null,
repoFileUrl: r.maintainerFile ?? null,
}))

const mappedAffiliations =
updatedAffiliations.length > 0
? updatedAffiliations.map(mapSegmentAffiliation)
: workExperiences.map(mapWorkExperienceAffiliation)

ok(res, {
id: segment.id,
projectSlug: segment.slug,
projectName: segment.name,
projectLogo: segment.projectLogo ?? null,
contributionCount: Number(segment.activityCount),
roles,
affiliations: mappedAffiliations,
})
}
113 changes: 113 additions & 0 deletions services/libs/data-access-layer/src/members/projectAffiliations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,35 @@ export async function fetchMemberProjectSegments(
)
}

/**
* Fetch a single project-level segment for a member + segment combination.
*/
export async function fetchMemberProjectSegment(
qx: QueryExecutor,
memberId: string,
segmentId: string,
): Promise<IProjectAffiliationSegment | null> {
const rows = await qx.select(
`
SELECT
s.id,
s.slug,
s.name,
msa."activityCount",
ip."logoUrl" AS "projectLogo"
FROM "memberSegmentsAgg" msa
JOIN segments s ON msa."segmentId" = s.id
LEFT JOIN "insightsProjects" ip ON ip."segmentId" = s.id AND ip."deletedAt" IS NULL
WHERE msa."memberId" = $(memberId)
AND s.id = $(segmentId)
AND s."parentSlug" IS NOT NULL
AND s."grandparentSlug" IS NULL
`,
{ memberId, segmentId },
)
return rows[0] ?? null
}

/**
* Fetch segment affiliations for a member with organization details.
* These are manual per-project overrides.
Expand Down Expand Up @@ -88,6 +117,90 @@ export async function fetchMemberSegmentAffiliationsWithOrg(
)
}

/**
* Fetch all segment affiliations for a member + project (segment) combination.
*/
export async function fetchMemberSegmentAffiliationsForProject(
qx: QueryExecutor,
memberId: string,
segmentId: string,
): Promise<ISegmentAffiliationWithOrg[]> {
return 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
JOIN organizations o ON msa."organizationId" = o.id
WHERE msa."memberId" = $(memberId)
AND msa."segmentId" = $(segmentId)
`,
{ memberId, segmentId },
)
}

export interface ISegmentAffiliationInsert {
organizationId: string
dateStart: string | null
dateEnd: string | null
verifiedBy: string
}

/**
* Delete all segment affiliations for a member + project (segment) combination.
*/
export async function deleteAllMemberSegmentAffiliationsForProject(
qx: QueryExecutor,
memberId: string,
segmentId: string,
): Promise<void> {
await qx.result(
`
DELETE FROM "memberSegmentAffiliations"
WHERE "memberId" = $(memberId)
AND "segmentId" = $(segmentId)
`,
Comment on lines +164 to +169
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.
{ memberId, segmentId },
)
}

/**
* Insert multiple segment affiliations for a member + project (segment) combination.
* All inserted affiliations are marked as verified.
*/
export async function insertMemberSegmentAffiliations(
qx: QueryExecutor,
memberId: string,
segmentId: string,
affiliations: ISegmentAffiliationInsert[],
): Promise<void> {
for (const aff of affiliations) {
await qx.result(
`
INSERT INTO "memberSegmentAffiliations"
(id, "memberId", "segmentId", "organizationId", "dateStart", "dateEnd", verified, "verifiedBy")
VALUES
(gen_random_uuid(), $(memberId), $(segmentId), $(organizationId), $(dateStart), $(dateEnd), true, $(verifiedBy))
`,
{
memberId,
segmentId,
organizationId: aff.organizationId,
dateStart: aff.dateStart,
dateEnd: aff.dateEnd,
verifiedBy: aff.verifiedBy,
},
)
}
}

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