Skip to content
Merged
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,125 @@
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,
fetchMemberProjectSegments,
fetchMemberSegmentAffiliationsForProject,
findMaintainerRoles,
findMemberById,
insertMemberSegmentAffiliations,
optionsQx,
} from '@crowd/data-access-layer'
import type { ISegmentAffiliationWithOrg } from '@crowd/data-access-layer'

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

import { mapSegmentAffiliation } 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 fetchMemberProjectSegments(qx, memberId, projectId)
if (!segment) {
throw new NotFoundError('Project not found')
}

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

let updatedAffiliations: ISegmentAffiliationWithOrg[] = []

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

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

const maintainerRoles = await findMaintainerRoles(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,
}))

ok(res, {
id: segment.id,
projectSlug: segment.slug,
projectName: segment.name,
projectLogo: segment.projectLogo ?? null,
contributionCount: Number(segment.activityCount),
roles,
affiliations: updatedAffiliations.map(mapSegmentAffiliation),
})
}
94 changes: 90 additions & 4 deletions services/libs/data-access-layer/src/members/projectAffiliations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,13 @@ export interface IWorkExperienceAffiliation {
}

/**
* Fetch all project-level segments a member has contributions in,
* along with contribution counts.
* Fetch project-level segments a member has contributions in,
* along with contribution counts. Optionally filter to a single segment by id.
*/
export async function fetchMemberProjectSegments(
qx: QueryExecutor,
memberId: string,
segmentId?: string,
): Promise<IProjectAffiliationSegment[]> {
return qx.select(
`
Expand All @@ -53,10 +54,11 @@ export async function fetchMemberProjectSegments(
LEFT JOIN "insightsProjects" ip ON ip."segmentId" = s.id AND ip."deletedAt" IS NULL
WHERE msa."memberId" = $(memberId)
AND s."parentSlug" IS NOT NULL
AND s."grandparentSlug" IS NULL
AND s."grandparentSlug" IS NOT NULL
${segmentId ? 'AND s.id = $(segmentId)' : ''}
ORDER BY msa."activityCount" DESC
`,
{ memberId },
{ memberId, segmentId },
)
}

Expand Down Expand Up @@ -88,6 +90,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)
`,
{ 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