diff --git a/backend/src/api/public/v1/members/index.ts b/backend/src/api/public/v1/members/index.ts index b9f108de5a..6aa6117c9f 100644 --- a/backend/src/api/public/v1/members/index.ts +++ b/backend/src/api/public/v1/members/index.ts @@ -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' @@ -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]), diff --git a/backend/src/api/public/v1/members/project-affiliations/getProjectAffiliations.ts b/backend/src/api/public/v1/members/project-affiliations/getProjectAffiliations.ts index e890a8826f..db3e18de43 100644 --- a/backend/src/api/public/v1/members/project-affiliations/getProjectAffiliations.ts +++ b/backend/src/api/public/v1/members/project-affiliations/getProjectAffiliations.ts @@ -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 { const { memberId } = validateOrThrow(paramsSchema, req.params) const qx = optionsQx(req) diff --git a/backend/src/api/public/v1/members/project-affiliations/mappers.ts b/backend/src/api/public/v1/members/project-affiliations/mappers.ts new file mode 100644 index 0000000000..eadea60090 --- /dev/null +++ b/backend/src/api/public/v1/members/project-affiliations/mappers.ts @@ -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, + } +} diff --git a/backend/src/api/public/v1/members/project-affiliations/patchProjectAffiliation.ts b/backend/src/api/public/v1/members/project-affiliations/patchProjectAffiliation.ts new file mode 100644 index 0000000000..88e49e29ad --- /dev/null +++ b/backend/src/api/public/v1/members/project-affiliations/patchProjectAffiliation.ts @@ -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 { + 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, + }) +} diff --git a/services/libs/data-access-layer/src/members/projectAffiliations.ts b/services/libs/data-access-layer/src/members/projectAffiliations.ts index e3423a5b37..bc183ec24d 100644 --- a/services/libs/data-access-layer/src/members/projectAffiliations.ts +++ b/services/libs/data-access-layer/src/members/projectAffiliations.ts @@ -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 { + 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. @@ -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 { + 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 { + 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 { + 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.