diff --git a/backend/package.json b/backend/package.json index 50fe7277b1..03e4d363dc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -138,7 +138,8 @@ "uuid": "^9.0.0", "validator": "^13.7.0", "verify-github-webhook": "^1.0.1", - "zlib-sync": "^0.1.8" + "zlib-sync": "^0.1.8", + "zod": "^4.3.6" }, "private": true, "devDependencies": { @@ -150,6 +151,7 @@ "@types/bunyan-format": "^0.2.5", "@types/config": "^3.3.0", "@types/cron": "^2.0.0", + "@types/express": "^4.17.17", "@types/html-to-text": "^8.1.1", "@types/node": "~18.0.4", "@types/sanitize-html": "^2.6.2", diff --git a/backend/src/api/index.ts b/backend/src/api/index.ts index 50f82754c1..05ef5df43e 100644 --- a/backend/src/api/index.ts +++ b/backend/src/api/index.ts @@ -147,6 +147,16 @@ setImmediate(async () => { app.use(bodyParser.urlencoded({ limit: '5mb', extended: true })) + app.use((req, res, next) => { + // @ts-ignore + req.userData = { + ip: req.ip, + userAgent: req.headers ? req.headers['user-agent'] : null, + } + + next() + }) + // Public API uses its own OAuth2 auth and error flow // Must be mounted before internal endpoints. app.use('/', publicRouter()) @@ -164,16 +174,6 @@ setImmediate(async () => { // to set the currentUser to the requests app.use(authMiddleware) - app.use((req, res, next) => { - // @ts-ignore - req.userData = { - ip: req.ip, - userAgent: req.headers ? req.headers['user-agent'] : null, - } - - next() - }) - app.use('/health', async (req: any, res) => { try { const seq = SequelizeRepository.getSequelize(req) diff --git a/backend/src/api/member/memberMerge.ts b/backend/src/api/member/memberMerge.ts index 684ebf1ba7..38b7b43562 100644 --- a/backend/src/api/member/memberMerge.ts +++ b/backend/src/api/member/memberMerge.ts @@ -1,8 +1,6 @@ -import { CommonMemberService } from '@crowd/common_services' +import { CommonMemberService, invalidateMemberQueryCache } from '@crowd/common_services' import { optionsQx } from '@crowd/data-access-layer' -import MemberService from '@/services/memberService' - import Permissions from '../../security/permissions' import track from '../../segment/track' import PermissionChecker from '../../services/user/permissionChecker' @@ -10,27 +8,20 @@ import PermissionChecker from '../../services/user/permissionChecker' export default async (req, res) => { new PermissionChecker(req).validateHas(Permissions.values.memberEdit) - const commonMemberService = new CommonMemberService(optionsQx(req), req.temporal, req.log) - const memberService = new MemberService(req) + const { memberId } = req.params + const { memberToMerge } = req.body + + const service = new CommonMemberService(optionsQx(req), req.temporal, req.log) - const payload = await commonMemberService.merge(req.params.memberId, req.body.memberToMerge, req) + const payload = await service.merge(memberId, memberToMerge, req) - // Invalidate member query cache after merge try { - await memberService.invalidateMemberQueryCache([req.params.memberId, req.body.memberToMerge]) - req.log.debug('Invalidated member query cache after merge') + await invalidateMemberQueryCache(req.redis, [memberId, memberToMerge]) } catch (error) { - // Don't fail the merge if cache invalidation fails - req.log.warn('Failed to invalidate member query cache after merge', { error }) + req.log.warn({ error }, 'Cache invalidation failed after member merge') } - track( - 'Merge members', - { memberId: req.params.memberId, memberToMergeId: req.body.memberToMerge }, - { ...req }, - ) - - const status = payload.status || 200 + track('Merge members', { memberId, memberToMergeId: memberToMerge }, req) - await req.responseHandler.success(req, res, payload, status) + return req.responseHandler.success(req, res, payload, payload.status ?? 200) } diff --git a/backend/src/api/member/memberUnmerge.ts b/backend/src/api/member/memberUnmerge.ts index fe6ee39b37..55106d7867 100644 --- a/backend/src/api/member/memberUnmerge.ts +++ b/backend/src/api/member/memberUnmerge.ts @@ -7,5 +7,5 @@ export default async (req, res) => { const payload = await new MemberService(req).unmerge(req.params.memberId, req.body) - await req.responseHandler.success(req, res, payload, 200) + return req.responseHandler.success(req, res, payload) } diff --git a/backend/src/api/public/middlewares/errorHandler.ts b/backend/src/api/public/middlewares/errorHandler.ts index 4cbdf8e82c..d8cdcafff1 100644 --- a/backend/src/api/public/middlewares/errorHandler.ts +++ b/backend/src/api/public/middlewares/errorHandler.ts @@ -5,6 +5,7 @@ import { } from 'express-oauth2-jwt-bearer' import { HttpError, InsufficientScopeError, InternalError, UnauthorizedError } from '@crowd/common' +import { SlackChannel, SlackPersona, sendSlackNotification } from '@crowd/slack' /** * Converts errors to structured JSON: `{ error: { code, message } }`. @@ -33,6 +34,35 @@ export const errorHandler: ErrorRequestHandler = ( return } + req.log.error( + { error, url: req.url, method: req.method, query: req.query, body: req.body }, + 'Unhandled error in public API', + ) + + sendSlackNotification( + SlackChannel.ALERTS, + SlackPersona.ERROR_REPORTER, + `Public API Error 500: ${req.method} ${req.url}`, + [ + { + title: 'Request', + text: `*Method:* \`${req.method}\`\n*URL:* \`${req.url}\``, + }, + { + title: 'Error', + text: `*Name:* \`${error?.name || 'Unknown'}\`\n*Message:* ${error?.message || 'No message'}`, + }, + ...(error?.stack + ? [ + { + title: 'Stack Trace', + text: `\`\`\`${error.stack.substring(0, 2700)}\`\`\``, + }, + ] + : []), + ], + ) + const unknownError = new InternalError() res.status(unknownError.status).json(unknownError.toJSON()) } diff --git a/backend/src/api/public/middlewares/oauth2Middleware.ts b/backend/src/api/public/middlewares/oauth2Middleware.ts index a538b27729..1a368e0ece 100644 --- a/backend/src/api/public/middlewares/oauth2Middleware.ts +++ b/backend/src/api/public/middlewares/oauth2Middleware.ts @@ -4,7 +4,7 @@ import { auth } from 'express-oauth2-jwt-bearer' import { UnauthorizedError } from '@crowd/common' import type { Auth0Configuration } from '@/conf/configTypes' -import type { ApiRequest, Auth0TokenPayload } from '@/types/api' +import type { Auth0TokenPayload } from '@/types/api' function resolveActor(req: Request, _res: Response, next: NextFunction): void { const payload = (req.auth?.payload ?? {}) as Auth0TokenPayload @@ -18,11 +18,9 @@ function resolveActor(req: Request, _res: Response, next: NextFunction): void { const id = rawId.replace(/@clients$/, '') - const authReq = req as ApiRequest - const scopes = typeof payload.scope === 'string' ? payload.scope.split(' ').filter(Boolean) : [] - authReq.actor = { id, type: 'service', scopes } + req.actor = { id, type: 'service', scopes } next() } diff --git a/backend/src/api/public/middlewares/requireScopes.ts b/backend/src/api/public/middlewares/requireScopes.ts index 4b508e0acb..31bad381c2 100644 --- a/backend/src/api/public/middlewares/requireScopes.ts +++ b/backend/src/api/public/middlewares/requireScopes.ts @@ -1,13 +1,12 @@ -import type { NextFunction, Response } from 'express' +import type { NextFunction, Request, Response } from 'express' import { InsufficientScopeError, UnauthorizedError } from '@crowd/common' import { Scope } from '@/security/scopes' -import type { ApiRequest } from '@/types/api' export const requireScopes = (required: Scope[], mode: 'all' | 'any' = 'all') => - (req: ApiRequest, _res: Response, next: NextFunction) => { + (req: Request, _res: Response, next: NextFunction) => { if (!req.actor) { next(new UnauthorizedError()) return diff --git a/backend/src/api/public/v1/identities/getIdentities.ts b/backend/src/api/public/v1/identities/getIdentities.ts deleted file mode 100644 index eaaa394ab1..0000000000 --- a/backend/src/api/public/v1/identities/getIdentities.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { Response } from 'express' - -import type { ApiRequest } from '@/types/api' - -export async function getIdentities(req: ApiRequest, res: Response): Promise { - res.status(200).json({ - status: 'ok', - actor: req.actor, - }) -} diff --git a/backend/src/api/public/v1/identities/index.ts b/backend/src/api/public/v1/identities/index.ts deleted file mode 100644 index 57e24e5fc5..0000000000 --- a/backend/src/api/public/v1/identities/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Router } from 'express' - -import { requireScopes } from '@/api/public/middlewares/requireScopes' -import { safeWrap } from '@/middlewares/errorMiddleware' -import { SCOPES } from '@/security/scopes' - -import { getIdentities } from './getIdentities' - -export function identitiesRouter(): Router { - const router = Router() - - router.get('/', requireScopes([SCOPES.READ_MEMBERS]), safeWrap(getIdentities)) - - return router -} diff --git a/backend/src/api/public/v1/index.ts b/backend/src/api/public/v1/index.ts index 23e70fa834..43ae7c7988 100644 --- a/backend/src/api/public/v1/index.ts +++ b/backend/src/api/public/v1/index.ts @@ -1,11 +1,13 @@ import { Router } from 'express' -import { identitiesRouter } from './identities' +import { membersRouter } from './members' +import { organizationsRouter } from './organizations' export function v1Router(): Router { const router = Router() - router.use('/identities', identitiesRouter()) + router.use('/members', membersRouter()) + router.use('/organizations', organizationsRouter()) return router } diff --git a/backend/src/api/public/v1/members/identities/getMemberIdentities.ts b/backend/src/api/public/v1/members/identities/getMemberIdentities.ts new file mode 100644 index 0000000000..621fecd717 --- /dev/null +++ b/backend/src/api/public/v1/members/identities/getMemberIdentities.ts @@ -0,0 +1,42 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' + +import { NotFoundError } from '@crowd/common' +import { + MemberField, + fetchMemberIdentities, + findMemberById, + optionsQx, +} from '@crowd/data-access-layer' + +import { ok } from '@/utils/api' +import { validateOrThrow } from '@/utils/validation' + +const paramsSchema = z.object({ + memberId: z.uuid(), +}) + +export async function getMemberIdentities(req: Request, res: Response): Promise { + const { memberId } = validateOrThrow(paramsSchema, req.params) + const qx = optionsQx(req) + + const member = await findMemberById(qx, memberId, [MemberField.ID]) + + if (!member) throw new NotFoundError('Member not found') + + const rawIdentities = await fetchMemberIdentities(qx, memberId) + + const identities = rawIdentities.map( + ({ id, value, platform, verified, source, createdAt, updatedAt }) => ({ + id, + value, + platform, + verified, + source, + createdAt, + updatedAt, + }), + ) + + ok(res, { identities }) +} diff --git a/backend/src/api/public/v1/members/identities/verifyMemberIdentity.ts b/backend/src/api/public/v1/members/identities/verifyMemberIdentity.ts new file mode 100644 index 0000000000..aadf0be683 --- /dev/null +++ b/backend/src/api/public/v1/members/identities/verifyMemberIdentity.ts @@ -0,0 +1,191 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' + +import { + captureApiChange, + memberUnmergeAction, + memberVerifyIdentityAction, +} from '@crowd/audit-logs' +import { InternalError, NotFoundError } from '@crowd/common' +import { + invalidateMemberQueryCache, + prepareMemberUnmerge, + startMemberUnmergeWorkflow, + unmergeMember, +} from '@crowd/common_services' +import { + MemberField, + deleteMemberIdentity, + findMemberById, + findMemberIdentityById, + optionsQx, + queryActivityRelations, + updateMemberIdentity, +} from '@crowd/data-access-layer' +import { SlackChannel, SlackPersona, sendSlackNotification } from '@crowd/slack' +import { + IMemberIdentity, + IMemberUnmergePreviewResult, + IUnmergePreviewResult, + MemberUnmergeResult, +} from '@crowd/types' + +import { noContent, ok } from '@/utils/api' +import { validateOrThrow } from '@/utils/validation' + +const paramsSchema = z.object({ + memberId: z.uuid(), + identityId: z.uuid(), +}) + +const bodySchema = z.object({ + verified: z.boolean(), + verifiedBy: z.string(), +}) + +type MemberUnmergeContext = { + preview: IUnmergePreviewResult + result: MemberUnmergeResult +} + +function toReturn(identity: IMemberIdentity) { + return { + id: identity.id, + value: identity.value, + platform: identity.platform, + verified: identity.verified, + source: identity.source, + createdAt: identity.createdAt, + updatedAt: identity.updatedAt, + } +} + +export async function verifyMemberIdentity(req: Request, res: Response): Promise { + const { memberId, identityId } = validateOrThrow(paramsSchema, req.params) + const { verified, 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 identity = await findMemberIdentityById(qx, memberId, identityId) + + if (!identity) throw new NotFoundError('Member identity not found') + + let unmerge: MemberUnmergeContext | undefined + let updatedIdentity: IMemberIdentity | undefined + + await captureApiChange( + req, + memberVerifyIdentityAction(memberId, async (captureOldState, captureNewState) => { + captureOldState(identity) + + await qx.tx(async (tx) => { + updatedIdentity = await updateMemberIdentity(tx, memberId, identityId, { + verified, + verifiedBy, + }) + + if (!updatedIdentity) { + throw new InternalError('Failed to update member identity') + } + + if (!verified) { + const { count } = await queryActivityRelations(tx, { + filter: { + and: [ + { + username: { eq: identity.value }, + platform: { eq: identity.platform }, + }, + ], + }, + limit: 1, + countOnly: true, + }) + + if (count === 0) { + await deleteMemberIdentity(tx, memberId, identityId) + } else { + const preview = await prepareMemberUnmerge(tx, memberId, identityId, false) + const result = await unmergeMember(tx, memberId, preview, req.actor.id) + unmerge = { preview, result } + } + } + }) + + captureNewState(updatedIdentity) + }), + ) + + if (unmerge) { + const { preview, result } = unmerge + + try { + await captureApiChange( + req, + memberUnmergeAction(memberId, async (captureOldState, captureNewState) => { + captureOldState({ primary: preview.primary }) + captureNewState({ + primary: result.primary, + secondary: result.secondary, + }) + }), + ) + } catch (error) { + req.log.warn({ error }, 'Audit log capture failed after identity unmerge') + sendSlackNotification( + SlackChannel.ALERTS, + SlackPersona.ERROR_REPORTER, + `Audit log capture failed after identity unmerge: member ${memberId}`, + [{ title: 'Error', text: `\`${error?.message || error}\`` }], + ) + } + + try { + await invalidateMemberQueryCache(req.redis, [result.primary.id, result.secondary.id], true) + } catch (error) { + req.log.warn({ error }, 'Cache invalidation failed after identity unmerge') + } + + try { + await startMemberUnmergeWorkflow(req.temporal, { + primaryId: result.primary.id, + secondaryId: result.secondary.id, + movedIdentities: result.movedIdentities, + primaryDisplayName: result.primary.displayName, + secondaryDisplayName: result.secondary.displayName, + actorId: req.actor.id, + }) + } catch (error) { + req.log.warn({ error }, 'Failed to start unmerge workflow after identity unmerge') + sendSlackNotification( + SlackChannel.ALERTS, + SlackPersona.ERROR_REPORTER, + `Failed to start unmerge workflow after identity unmerge: member ${memberId}`, + [ + { + title: 'Context', + text: `*Primary:* \`${result.primary.id}\`\n*Secondary:* \`${result.secondary.id}\``, + }, + { title: 'Error', text: `\`${error?.message || error}\`` }, + ], + ) + } + } + + // If verified = false and no activities (deleted): 204 No Content + if (!verified && !unmerge) { + noContent(res) + return + } + + // If verified = false and has activities (unmerge): 200 OK + unmergedToMemberId + ok(res, { + ...toReturn(updatedIdentity), + ...(unmerge && { unmergedToMemberId: unmerge.result.secondary.id }), + }) +} diff --git a/backend/src/api/public/v1/members/index.ts b/backend/src/api/public/v1/members/index.ts new file mode 100644 index 0000000000..02d02f9dfa --- /dev/null +++ b/backend/src/api/public/v1/members/index.ts @@ -0,0 +1,71 @@ +import { Router } from 'express' + +import { requireScopes } from '@/api/public/middlewares/requireScopes' +import { safeWrap } from '@/middlewares/errorMiddleware' +import { SCOPES } from '@/security/scopes' + +import { getMemberIdentities } from './identities/getMemberIdentities' +import { verifyMemberIdentity } from './identities/verifyMemberIdentity' +import { getMemberMaintainerRoles } from './maintainer-roles/getMemberMaintainerRoles' +import { resolveMemberByIdentities } from './resolveMember' +import { createMemberWorkExperience } from './work-experiences/createMemberWorkExperience' +import { deleteMemberWorkExperience } from './work-experiences/deleteMemberWorkExperience' +import { getMemberWorkExperiences } from './work-experiences/getMemberWorkExperiences' +import { updateMemberWorkExperience } from './work-experiences/updateMemberWorkExperience' +import { verifyMemberWorkExperience } from './work-experiences/verifyMemberWorkExperience' + +export function membersRouter(): Router { + const router = Router() + + router.post('/resolve', requireScopes([SCOPES.READ_MEMBERS]), safeWrap(resolveMemberByIdentities)) + + router.get( + '/:memberId/identities', + requireScopes([SCOPES.READ_MEMBER_IDENTITIES]), + safeWrap(getMemberIdentities), + ) + + router.patch( + '/:memberId/identities/:identityId', + requireScopes([SCOPES.WRITE_MEMBER_IDENTITIES]), + safeWrap(verifyMemberIdentity), + ) + + router.get( + '/:memberId/maintainer-roles', + requireScopes([SCOPES.READ_MAINTAINER_ROLES]), + safeWrap(getMemberMaintainerRoles), + ) + + router.post( + '/:memberId/work-experiences', + requireScopes([SCOPES.WRITE_WORK_EXPERIENCES]), + safeWrap(createMemberWorkExperience), + ) + + router.get( + '/:memberId/work-experiences', + requireScopes([SCOPES.READ_WORK_EXPERIENCES]), + safeWrap(getMemberWorkExperiences), + ) + + router.put( + '/:memberId/work-experiences/:workExperienceId', + requireScopes([SCOPES.WRITE_WORK_EXPERIENCES]), + safeWrap(updateMemberWorkExperience), + ) + + router.patch( + '/:memberId/work-experiences/:workExperienceId', + requireScopes([SCOPES.WRITE_WORK_EXPERIENCES]), + safeWrap(verifyMemberWorkExperience), + ) + + router.delete( + '/:memberId/work-experiences/:workExperienceId', + requireScopes([SCOPES.WRITE_WORK_EXPERIENCES]), + safeWrap(deleteMemberWorkExperience), + ) + + return router +} diff --git a/backend/src/api/public/v1/members/maintainer-roles/getMemberMaintainerRoles.ts b/backend/src/api/public/v1/members/maintainer-roles/getMemberMaintainerRoles.ts new file mode 100644 index 0000000000..b24dfec61c --- /dev/null +++ b/backend/src/api/public/v1/members/maintainer-roles/getMemberMaintainerRoles.ts @@ -0,0 +1,32 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' + +import { NotFoundError } from '@crowd/common' +import { + MemberField, + findMaintainerRoles, + findMemberById, + optionsQx, +} from '@crowd/data-access-layer' + +import { ok } from '@/utils/api' +import { validateOrThrow } from '@/utils/validation' + +const paramsSchema = z.object({ + memberId: z.uuid(), +}) + +export async function getMemberMaintainerRoles(req: Request, res: Response): Promise { + const { memberId } = validateOrThrow(paramsSchema, req.params) + const qx = optionsQx(req) + + const member = await findMemberById(qx, memberId, [MemberField.ID]) + + if (!member) { + throw new NotFoundError('Member not found') + } + + const maintainerRoles = await findMaintainerRoles(qx, [memberId]) + + ok(res, { maintainerRoles }) +} diff --git a/backend/src/api/public/v1/members/resolveMember.ts b/backend/src/api/public/v1/members/resolveMember.ts new file mode 100644 index 0000000000..43d6e948a2 --- /dev/null +++ b/backend/src/api/public/v1/members/resolveMember.ts @@ -0,0 +1,41 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' + +import { ConflictError, NotFoundError } from '@crowd/common' +import { findMemberIdsByIdentities, optionsQx } from '@crowd/data-access-layer' +import { IMemberIdentity, MemberIdentityType, PlatformType } from '@crowd/types' + +import { ok } from '@/utils/api' +import { validateOrThrow } from '@/utils/validation' + +const bodySchema = z.object({ + lfids: z.array(z.string().trim()).min(1, 'At least one lfid is required'), + emails: z.array(z.email()).optional(), +}) + +export async function resolveMemberByIdentities(req: Request, res: Response): Promise { + const { lfids, emails } = validateOrThrow(bodySchema, req.body) + + const qx = optionsQx(req) + + const identities: Partial[] = [ + ...lfids.map((lfid) => ({ + platform: PlatformType.LFID, + type: MemberIdentityType.USERNAME, + value: lfid, + })), + ...(emails?.map((email) => ({ type: MemberIdentityType.EMAIL, value: email })) ?? []), + ] + + const memberIds = await findMemberIdsByIdentities(qx, identities) + + if (memberIds.length === 0) { + throw new NotFoundError('Member not found') + } else if (memberIds.length > 1) { + throw new ConflictError('Conflicting identities') + } + + const memberId = memberIds[0] + + ok(res, { memberId }) +} diff --git a/backend/src/api/public/v1/members/work-experiences/createMemberWorkExperience.ts b/backend/src/api/public/v1/members/work-experiences/createMemberWorkExperience.ts new file mode 100644 index 0000000000..1e8492799c --- /dev/null +++ b/backend/src/api/public/v1/members/work-experiences/createMemberWorkExperience.ts @@ -0,0 +1,109 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' + +import { captureApiChange, memberEditOrganizationsAction } from '@crowd/audit-logs' +import { ConflictError, NotFoundError } from '@crowd/common' +import { CommonMemberService } from '@crowd/common_services' +import { + MemberField, + changeMemberOrganizationAffiliationOverrides, + checkOrganizationAffiliationPolicy, + cleanSoftDeletedMemberOrganization, + createMemberOrganization, + fetchManyMemberOrgsWithOrgData, + findMemberById, + optionsQx, +} from '@crowd/data-access-layer' +import type { IMemberOrganization, IMemberRoleWithOrganization } from '@crowd/types' + +import { created } from '@/utils/api' +import { toMemberWorkExperience } from '@/utils/mapper' +import { validateOrThrow } from '@/utils/validation' + +const paramsSchema = z.object({ + memberId: z.uuid(), +}) + +const bodySchema = z.object({ + organizationId: z.uuid(), + jobTitle: z.string(), + verified: z.boolean(), + verifiedBy: z.string(), + source: z.string(), + startDate: z.coerce.date(), + endDate: z.coerce.date().nullable().optional(), +}) + +export async function createMemberWorkExperience(req: Request, res: Response): Promise { + const { memberId } = 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') + } + + let createdMo: IMemberRoleWithOrganization | undefined + + await captureApiChange( + req, + memberEditOrganizationsAction(memberId, async (captureOldState, captureNewState) => { + captureOldState({}) + + const memberOrgData: IMemberOrganization = { + memberId, + organizationId: data.organizationId, + title: data.jobTitle, + dateStart: data.startDate, + dateEnd: data.endDate, + source: data.source, + verified: data.verified, + verifiedBy: data.verifiedBy, + } + + let newMemberOrgId: string | undefined + + await qx.tx(async (tx) => { + await cleanSoftDeletedMemberOrganization(tx, memberId, data.organizationId, data) + + newMemberOrgId = await createMemberOrganization(tx, memberId, memberOrgData) + + if (!newMemberOrgId) { + throw new ConflictError('A work experience with the same dates already exists') + } + + const isAffiliationBlocked = await checkOrganizationAffiliationPolicy( + tx, + data.organizationId, + ) + + if (newMemberOrgId && isAffiliationBlocked) { + await changeMemberOrganizationAffiliationOverrides(tx, [ + { + memberId, + memberOrganizationId: newMemberOrgId, + allowAffiliation: false, + }, + ]) + } + + const service = new CommonMemberService(tx, req.temporal, req.log) + await service.startAffiliationRecalculation(memberId, [data.organizationId]) + }) + + const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId]) + createdMo = (orgsMap.get(memberId) ?? []).find((mo) => mo.id === newMemberOrgId) + + captureNewState(createdMo ?? null) + }), + ) + + if (!createdMo) { + throw new NotFoundError('Work experience not found after creation') + } + + created(res, toMemberWorkExperience(createdMo)) +} diff --git a/backend/src/api/public/v1/members/work-experiences/deleteMemberWorkExperience.ts b/backend/src/api/public/v1/members/work-experiences/deleteMemberWorkExperience.ts new file mode 100644 index 0000000000..67ca87e14e --- /dev/null +++ b/backend/src/api/public/v1/members/work-experiences/deleteMemberWorkExperience.ts @@ -0,0 +1,60 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' + +import { captureApiChange, memberEditOrganizationsAction } from '@crowd/audit-logs' +import { NotFoundError } from '@crowd/common' +import { CommonMemberService } from '@crowd/common_services' +import { + MemberField, + deleteMemberOrganizations, + fetchManyMemberOrgsWithOrgData, + findMemberById, + optionsQx, +} from '@crowd/data-access-layer' + +import { noContent } from '@/utils/api' +import { validateOrThrow } from '@/utils/validation' + +const paramsSchema = z.object({ + memberId: z.uuid(), + workExperienceId: z.uuid(), +}) + +export async function deleteMemberWorkExperience(req: Request, res: Response): Promise { + const { memberId, workExperienceId } = validateOrThrow(paramsSchema, req.params) + + const qx = optionsQx(req) + + const member = await findMemberById(qx, memberId, [MemberField.ID]) + + if (!member) { + throw new NotFoundError('Member not found') + } + + const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId]) + + const memberOrg = (orgsMap.get(memberId) ?? []).find((mo) => mo.id === workExperienceId) + + if (!memberOrg) { + throw new NotFoundError('Work experience not found') + } + + await captureApiChange( + req, + memberEditOrganizationsAction(memberId, async (captureOldState, captureNewState) => { + captureOldState(memberOrg) + + await qx.tx(async (tx) => { + await deleteMemberOrganizations(tx, memberId, [workExperienceId]) + + const commonMemberService = new CommonMemberService(tx, req.temporal, req.log) + await commonMemberService.startAffiliationRecalculation(memberId, [ + memberOrg.organizationId, + ]) + }) + + captureNewState(null) + }), + ) + noContent(res) +} diff --git a/backend/src/api/public/v1/members/work-experiences/getMemberWorkExperiences.ts b/backend/src/api/public/v1/members/work-experiences/getMemberWorkExperiences.ts new file mode 100644 index 0000000000..3b68035b08 --- /dev/null +++ b/backend/src/api/public/v1/members/work-experiences/getMemberWorkExperiences.ts @@ -0,0 +1,34 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' + +import { NotFoundError } from '@crowd/common' +import { + MemberField, + fetchManyMemberOrgsWithOrgData, + findMemberById, + optionsQx, +} from '@crowd/data-access-layer' + +import { ok } from '@/utils/api' +import { toMemberWorkExperience } from '@/utils/mapper' +import { validateOrThrow } from '@/utils/validation' + +const paramsSchema = z.object({ + memberId: z.uuid(), +}) + +export async function getMemberWorkExperiences(req: Request, res: Response): Promise { + const { memberId } = validateOrThrow(paramsSchema, req.params) + const qx = optionsQx(req) + + const member = await findMemberById(qx, memberId, [MemberField.ID]) + + if (!member) { + throw new NotFoundError('Member not found') + } + + const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId]) + const workExperiences = (orgsMap.get(memberId) ?? []).map(toMemberWorkExperience) + + ok(res, { memberId, workExperiences }) +} diff --git a/backend/src/api/public/v1/members/work-experiences/updateMemberWorkExperience.ts b/backend/src/api/public/v1/members/work-experiences/updateMemberWorkExperience.ts new file mode 100644 index 0000000000..f0a58bbec5 --- /dev/null +++ b/backend/src/api/public/v1/members/work-experiences/updateMemberWorkExperience.ts @@ -0,0 +1,93 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' + +import { captureApiChange, memberEditOrganizationsAction } from '@crowd/audit-logs' +import { NotFoundError } from '@crowd/common' +import { CommonMemberService } from '@crowd/common_services' +import { + MemberField, + cleanSoftDeletedMemberOrganization, + fetchManyMemberOrgsWithOrgData, + findMemberById, + optionsQx, + updateMemberOrganization, +} from '@crowd/data-access-layer' +import type { MemberOrganizationUpdate } from '@crowd/types' + +import { ok } from '@/utils/api' +import { toMemberWorkExperience } from '@/utils/mapper' +import { validateOrThrow } from '@/utils/validation' + +const paramsSchema = z.object({ + memberId: z.uuid(), + workExperienceId: z.uuid(), +}) + +const bodySchema = z.object({ + organizationId: z.uuid(), + jobTitle: z.string(), + verified: z.boolean(), + verifiedBy: z.string(), + source: z.string(), + startDate: z.coerce.date(), + endDate: z.coerce.date().nullable().optional(), +}) + +export async function updateMemberWorkExperience(req: Request, res: Response): Promise { + const { memberId, workExperienceId } = 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 orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId]) + const existing = (orgsMap.get(memberId) ?? []).find((mo) => mo.id === workExperienceId) + + if (!existing) { + throw new NotFoundError('Work experience not found') + } + + const update: MemberOrganizationUpdate = { + organizationId: data.organizationId, + title: data.jobTitle, + verified: data.verified, + verifiedBy: data.verifiedBy, + source: data.source, + dateStart: data.startDate, + dateEnd: data.endDate, + } + + let updated: ReturnType | undefined + + await captureApiChange( + req, + memberEditOrganizationsAction(memberId, async (captureOldState, captureNewState) => { + captureOldState(existing) + + await qx.tx(async (tx) => { + await cleanSoftDeletedMemberOrganization(tx, memberId, data.organizationId, update) + await updateMemberOrganization(tx, memberId, workExperienceId, update) + + const service = new CommonMemberService(tx, req.temporal, req.log) + await service.startAffiliationRecalculation(memberId, [data.organizationId]) + }) + + const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId]) + const updatedMo = (orgsMap.get(memberId) ?? []).find((mo) => mo.id === workExperienceId) + + if (!updatedMo) { + throw new NotFoundError('Work experience not found') + } + + captureNewState(updatedMo) + updated = toMemberWorkExperience(updatedMo) + }), + ) + + ok(res, updated) +} diff --git a/backend/src/api/public/v1/members/work-experiences/verifyMemberWorkExperience.ts b/backend/src/api/public/v1/members/work-experiences/verifyMemberWorkExperience.ts new file mode 100644 index 0000000000..eae6c6b34d --- /dev/null +++ b/backend/src/api/public/v1/members/work-experiences/verifyMemberWorkExperience.ts @@ -0,0 +1,76 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' + +import { captureApiChange, memberVerifyWorkExperienceAction } from '@crowd/audit-logs' +import { NotFoundError } from '@crowd/common' +import { CommonMemberService } from '@crowd/common_services' +import { + MemberField, + deleteMemberOrganizations, + fetchManyMemberOrgsWithOrgData, + findMemberById, + optionsQx, + updateMemberOrganization, +} from '@crowd/data-access-layer' +import { IMemberOrganization } from '@crowd/types' + +import { ok } from '@/utils/api' +import { toMemberWorkExperience } from '@/utils/mapper' +import { validateOrThrow } from '@/utils/validation' + +const paramsSchema = z.object({ + memberId: z.uuid(), + workExperienceId: z.uuid(), +}) + +const bodySchema = z.object({ + verified: z.boolean(), + verifiedBy: z.string(), +}) + +export async function verifyMemberWorkExperience(req: Request, res: Response): Promise { + const { memberId, workExperienceId } = validateOrThrow(paramsSchema, req.params) + const { verified, 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 orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId]) + const memberOrg = (orgsMap.get(memberId) ?? []).find((mo) => mo.id === workExperienceId) + + if (!memberOrg) { + throw new NotFoundError('Work experience not found') + } + + let updatedMemberOrg: IMemberOrganization | undefined + + await captureApiChange( + req, + memberVerifyWorkExperienceAction(memberId, async (captureOldState, captureNewState) => { + captureOldState(memberOrg) + + await qx.tx(async (tx) => { + if (verified) { + updatedMemberOrg = await updateMemberOrganization(tx, memberId, workExperienceId, { + verified, + verifiedBy, + }) + } else { + await deleteMemberOrganizations(tx, memberId, [workExperienceId], true) + + const service = new CommonMemberService(tx, req.temporal, req.log) + await service.startAffiliationRecalculation(memberId, [memberOrg.organizationId]) + } + }) + + captureNewState(updatedMemberOrg ?? { ...memberOrg, verified, verifiedBy }) + }), + ) + + ok(res, toMemberWorkExperience({ ...memberOrg, ...updatedMemberOrg })) +} diff --git a/backend/src/api/public/v1/organizations/createOrganization.ts b/backend/src/api/public/v1/organizations/createOrganization.ts new file mode 100644 index 0000000000..b95111fc0c --- /dev/null +++ b/backend/src/api/public/v1/organizations/createOrganization.ts @@ -0,0 +1,63 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' + +import { captureApiChange, organizationCreateAction } from '@crowd/audit-logs' +import { InternalError } from '@crowd/common' +import { findOrCreateOrganization, optionsQx } from '@crowd/data-access-layer' +import { OrganizationAttributeSource, OrganizationIdentityType } from '@crowd/types' + +import { created } from '@/utils/api' +import { validateOrThrow } from '@/utils/validation' + +const bodySchema = z.object({ + name: z.string().trim().min(1), + domain: z.string().trim().min(1), + source: z.string().trim().min(1), +}) + +export async function createOrganization(req: Request, res: Response): Promise { + const { name, domain, source } = validateOrThrow(bodySchema, req.body) + + const qx = optionsQx(req) + + let organizationId: string | undefined + + await qx.tx(async (tx) => { + const orgSource = OrganizationAttributeSource.CUSTOM + + organizationId = await findOrCreateOrganization(tx, orgSource, { + displayName: name, + identities: [ + { + value: domain, + type: OrganizationIdentityType.PRIMARY_DOMAIN, + verified: true, + platform: orgSource, + source, + }, + ], + }) + + if (!organizationId) { + throw new InternalError('Failed to create organization') + } + + await captureApiChange( + req, + organizationCreateAction(organizationId, async (captureState) => { + captureState({ + id: organizationId, + displayName: name, + identities: [ + { + value: domain, + type: OrganizationIdentityType.PRIMARY_DOMAIN, + }, + ], + }) + }), + ) + }) + + created(res, { id: organizationId, name }) +} diff --git a/backend/src/api/public/v1/organizations/getOrganization.ts b/backend/src/api/public/v1/organizations/getOrganization.ts new file mode 100644 index 0000000000..5f4afccb45 --- /dev/null +++ b/backend/src/api/public/v1/organizations/getOrganization.ts @@ -0,0 +1,57 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' + +import { NotFoundError, normalizeHostname } from '@crowd/common' +import { + OrgIdentityField, + OrganizationField, + findOrgAttributes, + findOrgById, + optionsQx, + queryOrgIdentities, +} from '@crowd/data-access-layer' +import { OrganizationIdentityType } from '@crowd/types' + +import { ok } from '@/utils/api' +import { validateOrThrow } from '@/utils/validation' + +const querySchema = z.object({ + domain: z.string().trim().min(1), +}) + +export async function getOrganization(req: Request, res: Response): Promise { + const { domain } = validateOrThrow(querySchema, req.query) + + const qx = optionsQx(req) + + const results = await queryOrgIdentities(qx, { + fields: [OrgIdentityField.ORGANIZATION_ID], + filter: { + and: [ + { value: { eq: normalizeHostname(domain, false) } }, + { type: { eq: OrganizationIdentityType.PRIMARY_DOMAIN } }, + { verified: { eq: true } }, + ], + }, + }) + + const organizationId = results[0]?.organizationId + + if (!organizationId) { + throw new NotFoundError('Organization not found') + } + + const org = await findOrgById(qx, organizationId, [ + OrganizationField.ID, + OrganizationField.DISPLAY_NAME, + ]) + + const attributes = await findOrgAttributes(qx, organizationId) + const logo = attributes.find((a) => a.name === 'logo')?.value + + ok(res, { + id: org.id, + name: org.displayName, + ...(logo ? { logo } : {}), + }) +} diff --git a/backend/src/api/public/v1/organizations/index.ts b/backend/src/api/public/v1/organizations/index.ts new file mode 100644 index 0000000000..e15ee83e30 --- /dev/null +++ b/backend/src/api/public/v1/organizations/index.ts @@ -0,0 +1,18 @@ +import { Router } from 'express' + +import { safeWrap } from '@/middlewares/errorMiddleware' +import { SCOPES } from '@/security/scopes' + +import { requireScopes } from '../../middlewares/requireScopes' + +import { createOrganization } from './createOrganization' +import { getOrganization } from './getOrganization' + +export function organizationsRouter(): Router { + const router = Router() + + router.get('/', requireScopes([SCOPES.READ_ORGANIZATIONS]), safeWrap(getOrganization)) + router.post('/', requireScopes([SCOPES.WRITE_ORGANIZATIONS]), safeWrap(createOrganization)) + + return router +} diff --git a/backend/src/database/repositories/member/memberOrganizationAffiliationOverridesRepository.ts b/backend/src/database/repositories/member/memberOrganizationAffiliationOverridesRepository.ts index ccefc6b003..6e4d224cc2 100644 --- a/backend/src/database/repositories/member/memberOrganizationAffiliationOverridesRepository.ts +++ b/backend/src/database/repositories/member/memberOrganizationAffiliationOverridesRepository.ts @@ -2,7 +2,7 @@ import { changeMemberOrganizationAffiliationOverrides, findMemberAffiliationOverrides, findPrimaryWorkExperiencesOfMember, -} from '@crowd/data-access-layer/src/member_organization_affiliation_overrides' +} from '@crowd/data-access-layer' import { IChangeAffiliationOverrideData, IMemberOrganizationAffiliationOverride, diff --git a/backend/src/security/scopes.ts b/backend/src/security/scopes.ts index dd5b16e704..a26b8af449 100644 --- a/backend/src/security/scopes.ts +++ b/backend/src/security/scopes.ts @@ -1,12 +1,14 @@ export const SCOPES = { READ_MEMBERS: 'read:members', - READ_IDENTITIES: 'read:identities', - WRITE_IDENTITIES: 'write:identities', - READ_ROLES: 'read:roles', + READ_ORGANIZATIONS: 'read:organizations', + WRITE_ORGANIZATIONS: 'write:organizations', + READ_MEMBER_IDENTITIES: 'read:member-identities', + WRITE_MEMBER_IDENTITIES: 'write:member-identities', + READ_MAINTAINER_ROLES: 'read:maintainer-roles', READ_WORK_EXPERIENCES: 'read:work-experiences', WRITE_WORK_EXPERIENCES: 'write:work-experiences', - READ_PROJECTS_AFFILIATIONS: 'read:projects-affiliations', - WRITE_PROJECTS_AFFILIATIONS: 'write:projects-affiliations', + READ_PROJECT_AFFILIATIONS: 'read:project-affiliations', + WRITE_PROJECT_AFFILIATIONS: 'write:project-affiliations', } as const export type Scope = (typeof SCOPES)[keyof typeof SCOPES] diff --git a/backend/src/services/member/memberOrganizationsService.ts b/backend/src/services/member/memberOrganizationsService.ts index 81fa18b99c..251ea59019 100644 --- a/backend/src/services/member/memberOrganizationsService.ts +++ b/backend/src/services/member/memberOrganizationsService.ts @@ -5,21 +5,24 @@ import { Error404 } from '@crowd/common' import { CommonMemberService } from '@crowd/common_services' import { OrganizationField, + changeMemberOrganizationAffiliationOverrides, checkOrganizationAffiliationPolicy, cleanSoftDeletedMemberOrganization, createMemberOrganization, deleteMemberOrganizations, fetchMemberOrganizations, + findMemberAffiliationOverrides, optionsQx, queryOrgs, updateMemberOrganization, } from '@crowd/data-access-layer' -import { - changeMemberOrganizationAffiliationOverrides, - findMemberAffiliationOverrides, -} from '@crowd/data-access-layer/src/member_organization_affiliation_overrides' import { LoggerBase } from '@crowd/logging' -import { IMemberOrganization, IOrganization, IRenderFriendlyMemberOrganization } from '@crowd/types' +import { + IMemberOrganization, + IOrganization, + IRenderFriendlyMemberOrganization, + MemberOrganizationUpdate, +} from '@crowd/types' import SequelizeRepository from '@/database/repositories/sequelizeRepository' @@ -210,8 +213,20 @@ export default class MemberOrganizationsService extends LoggerBase { try { const qx = SequelizeRepository.getQueryExecutor(repositoryOptions) + const update: MemberOrganizationUpdate = Object.fromEntries( + Object.entries({ + organizationId: data.organizationId, + title: data.title, + dateStart: data.dateStart, + dateEnd: data.dateEnd, + source: data.source, + verified: data.verified, + verifiedBy: data.verifiedBy, + }).filter(([, v]) => v !== undefined), + ) + await cleanSoftDeletedMemberOrganization(qx, memberId, data.organizationId, data) - await updateMemberOrganization(qx, memberId, id, data) + await updateMemberOrganization(qx, memberId, id, update) await this.commonMemberService.startAffiliationRecalculation(memberId, [data.organizationId]) diff --git a/backend/src/services/memberService.ts b/backend/src/services/memberService.ts index a9434950d7..a853dddf2b 100644 --- a/backend/src/services/memberService.ts +++ b/backend/src/services/memberService.ts @@ -1,54 +1,44 @@ /* eslint-disable no-continue */ -import { randomUUID } from 'crypto' import lodash from 'lodash' import moment from 'moment-timezone' import validator from 'validator' import { captureApiChange, memberUnmergeAction } from '@crowd/audit-logs' import { Error400, calculateReach, getProperDisplayName, isDomainExcluded } from '@crowd/common' -import { CommonMemberService, getGithubInstallationToken } from '@crowd/common_services' -import { findMemberAffiliations } from '@crowd/data-access-layer/src/member_segment_affiliations' import { - MemberField, - MemberQueryCache, - addMemberRole, - fetchManyMemberOrgsWithOrgData, + CommonMemberService, + getGithubInstallationToken, + invalidateMemberQueryCache, + prepareMemberUnmerge, + startMemberUnmergeWorkflow, + unmergeMember, +} from '@crowd/common_services' +import { fetchMemberBotSuggestionsBySegment, fetchMemberIdentities, - findMemberById, - findMemberIdentitiesByValue, findMemberIdentityById, insertMemberSegmentAggregates, queryMembersAdvanced, - removeMemberRole, } from '@crowd/data-access-layer/src/members' -import { addMergeAction, setMergeAction } from '@crowd/data-access-layer/src/mergeActions/repo' import { QueryExecutor, optionsQx } from '@crowd/data-access-layer/src/queryExecutor' import { fetchManySegments } from '@crowd/data-access-layer/src/segments' import { LoggerBase } from '@crowd/logging' import { IMemberIdentity, - IMemberRoleWithOrganization, IMemberUnmergeBackup, IMemberUnmergePreviewResult, IOrganization, IUnmergePreviewResult, MemberAttributeType, MemberIdentityType, - MemberRoleUnmergeStrategy, - MergeActionState, - MergeActionStep, MergeActionType, OrganizationIdentityType, SyncMode, } from '@crowd/types' -import { MergeActionsRepository } from '@/database/repositories/mergeActionsRepository' -import OrganizationRepository from '@/database/repositories/organizationRepository' - -import { IRepositoryOptions } from '../database/repositories/IRepositoryOptions' import MemberAttributeSettingsRepository from '../database/repositories/memberAttributeSettingsRepository' import MemberRepository from '../database/repositories/memberRepository' +import { MergeActionsRepository } from '../database/repositories/mergeActionsRepository' import SequelizeRepository from '../database/repositories/sequelizeRepository' import { BasicMemberIdentity, @@ -60,7 +50,6 @@ import telemetryTrack from '../segment/telemetryTrack' import { IServiceOptions } from './IServiceOptions' import MemberAttributeSettingsService from './memberAttributeSettingsService' -import MemberOrganizationService from './memberOrganizationService' import OrganizationService from './organizationService' import SearchSyncService from './searchSyncService' import SettingsService from './settingsService' @@ -73,6 +62,18 @@ export default class MemberService extends LoggerBase { this.options = options } + static normalizeIds(ids: unknown): string[] { + if (typeof ids === 'string') { + return ids.length > 0 ? [ids] : [] + } + + if (Array.isArray(ids)) { + return ids.filter((id): id is string => typeof id === 'string' && id.length > 0) + } + + return [] + } + /** * Validates the attributes against its saved settings. * @@ -596,514 +597,61 @@ export default class MemberService extends LoggerBase { return existing } - /** - * Unmerges two members given a preview payload. - * Payload is returned from unmerge/preview endpoint, and confirmed by the user - * Payload.primary has the primary member's unmerged data, current member will be updated using these fields. - * Payload.secondary has the member that'll be unmerged/extracted from the primary member. This member will be created - * Activity moving, syncing to opensearch, recalculating activity.organizationIds and notifying frontend via websockets - * is done asynchronously, via entity-merge temporal worker finishMemberUnmerging workflow. - * @param memberId memberId of the primary member - * @param payload unmerge preview payload - */ - async unmerge( - memberId: string, - payload: IUnmergePreviewResult, - ): Promise { - let tx - - // this field is purely for rendering the preview, we'll set the secondary member roles using the payload.secondary.memberOrganizations field - // consequentially this field is checked in member.create - we'll instead handle roles manually after creation - delete payload.secondary.organizations - - try { - const { member, secondaryMember } = await captureApiChange( - this.options, - memberUnmergeAction(memberId, async (captureOldState, captureNewState) => { - const qx = SequelizeRepository.getQueryExecutor(this.options) - const member = await findMemberById(qx, memberId, [ - MemberField.ID, - MemberField.DISPLAY_NAME, - ]) - - captureOldState({ - primary: payload.primary, - }) - - const repoOptions: IRepositoryOptions = - await SequelizeRepository.createTransactionalRepositoryOptions(this.options) - tx = repoOptions.transaction - - // remove identities in secondary member from primary member - await MemberRepository.removeIdentitiesFromMember( - memberId, - payload.secondary.identities.filter( - (i) => - i.verified === undefined || // backwards compatibility for old identity backups - i.verified === true || - (i.verified === false && - !payload.primary.identities.some( - (pi) => - pi.verified === false && - pi.platform === i.platform && - pi.value === i.value && - pi.type === i.type, - )), - ), - repoOptions, - ) - - // we need to exclude identities in secondary that still exists in some other member - const identitiesToExclude = await MemberRepository.findAlreadyExistingIdentities( - payload.secondary.identities.filter((i) => i.verified), - repoOptions, - ) - - payload.secondary.identities = payload.secondary.identities.filter( - (i) => - !identitiesToExclude.some( - (ie) => - ie.platform === i.platform && - ie.value === i.value && - ie.type === i.type && - ie.verified, - ), - ) - - // create the secondary member - const secondaryMember = await MemberRepository.create(payload.secondary, repoOptions) - - // track merge action - await addMergeAction( - optionsQx(repoOptions), - MergeActionType.MEMBER, - member.id, - secondaryMember.id, - MergeActionStep.UNMERGE_STARTED, - MergeActionState.IN_PROGRESS, - undefined, - this.options.currentUser?.id, - ) - - // move affiliations - if (payload.secondary.affiliations.length > 0) { - await MemberRepository.moveSelectedAffiliationsBetweenMembers( - memberId, - secondaryMember.id, - payload.secondary.affiliations.map((a) => a.id), - repoOptions, - ) - } - - // move memberOrganizations - if (payload.secondary.memberOrganizations.length > 0) { - const nonExistingOrganizationIds = await OrganizationRepository.findNonExistingIds( - payload.secondary.memberOrganizations.map((o) => o.organizationId), - repoOptions, - ) - for (const role of payload.secondary.memberOrganizations.filter( - (r) => !nonExistingOrganizationIds.includes(r.organizationId), - )) { - await addMemberRole(optionsQx(repoOptions), { ...role, memberId: secondaryMember.id }) - } - - const memberOrganizationsMap = await fetchManyMemberOrgsWithOrgData( - optionsQx(repoOptions), - [member.id], - ) - - const memberOrganizations = memberOrganizationsMap.get(member.id) - - // check if anything to delete in primary - const rolesToDelete = memberOrganizations.filter( - (r) => - r.source !== 'ui' && - !payload.primary.memberOrganizations.some( - (pr) => - pr.organizationId === r.organizationId && - pr.title === r.title && - pr.dateStart === r.dateStart && - pr.dateEnd === r.dateEnd, - ), - ) - - for (const role of rolesToDelete) { - await removeMemberRole(optionsQx(repoOptions), role) - } - } - - // delete relations from payload, since we already handled those - delete payload.primary.identities - delete payload.primary.username - delete payload.primary.memberOrganizations - delete payload.primary.organizations - delete payload.primary.affiliations - - captureNewState({ - primary: payload.primary, - secondary: { - ...payload.secondary, - ...secondaryMember, - }, - }) - - // update rest of the primary member fields - await MemberRepository.update(memberId, payload.primary, repoOptions) - - // add primary and secondary to no merge so they don't get suggested again - await MemberRepository.addNoMerge(memberId, secondaryMember.id, repoOptions) - - // trigger entity-merging-worker to move activities in the background - await SequelizeRepository.commitTransaction(tx) - - // Invalidate member query cache after unmerge - await this.invalidateMemberQueryCache([memberId, secondaryMember.id], true) - - return { member, secondaryMember } - }), - ) - - await setMergeAction( - optionsQx(this.options), - MergeActionType.MEMBER, - member.id, - secondaryMember.id, - { - step: MergeActionStep.UNMERGE_SYNC_DONE, - }, - ) - - // responsible for moving member's activities, syncing to opensearch afterwards, recalculating activity.organizationIds and notifying frontend via websockets - await this.options.temporal.workflow.start('finishMemberUnmerging', { - taskQueue: 'entity-merging', - workflowId: `finishMemberUnmerging/${member.id}/${secondaryMember.id}`, - retry: { - maximumAttempts: 10, - }, - args: [ - member.id, - secondaryMember.id, - payload.secondary.identities, - member.displayName, - secondaryMember.displayName, - this.options.currentUser.id, - ], - searchAttributes: { - TenantId: [this.options.currentTenant.id], - }, - }) - } catch (err) { - if (tx) { - await SequelizeRepository.rollbackTransaction(tx) - } - throw err - } - } - - /** - * Returns a preview of primary and secondary members after a possible unmerge operation - * If revertMerge flag is set, preview will be for an in-place unmerge between two members with the corresponding mergeAction.unmergeBackup - * Otherwise, preview will be for an identity extraction - * For email identities, all related identities across sources will be extracted - * This will only return a preview, users will be able to edit the preview and confirm the payload - * Unmerge will be done in /unmerge endpoint with the confirmed payload from the user. - * @param memberId member for identity extraction/unmerge - * @param identityId identity to be extracted/unmerged - * @param revertMerge flag to determine if we should revert a merge or do an identity extraction - */ async unmergePreview( memberId: string, identityId: string, - revertPreviousMerge: boolean = false, + revertPreviousMerge = false, ): Promise> { - const relationships = ['identities', 'affiliations'] - - try { - const qx = SequelizeRepository.getQueryExecutor(this.options) - const memberById = await findMemberById(qx, memberId, [ - MemberField.ID, - MemberField.TENANT_ID, - MemberField.DISPLAY_NAME, - MemberField.ATTRIBUTES, - MemberField.REACH, - MemberField.CONTRIBUTIONS, - MemberField.MANUALLY_CHANGED_FIELDS, - ]) - - this.options.log.info('[0] Getting member information (identities, affiliations)... ') - - const [memberOrganizationsMap, identities, affiliations] = await Promise.all([ - fetchManyMemberOrgsWithOrgData(qx, [memberId]), - fetchMemberIdentities(qx, memberId), - findMemberAffiliations(qx, memberId), - ]) - - this.options.log.info('[0] Done!') - - const memberOrganizations = memberOrganizationsMap.get(memberId) - const member = { - ...memberById, - memberOrganizations, - identities, - affiliations, - } - - const identity = await findMemberIdentityById(qx, memberId, identityId) - if (!identity) { - throw new Error400(this.options.language, 'merge.errors.identityMismatch') - } - - if (revertPreviousMerge) { - this.options.log.info('[1] Finding merge backup...') - - const mergeAction = await MergeActionsRepository.findMergeBackup( - memberId, - MergeActionType.MEMBER, - identity, - this.options, - ) - - if (!mergeAction) { - throw new Error('No previous merge action found to revert for member!') - } + const qx = SequelizeRepository.getQueryExecutor(this.options) + return prepareMemberUnmerge(qx, memberId, identityId, revertPreviousMerge) + } - this.options.log.info('[1] Done!') + async unmerge( + memberId: string, + payload: IUnmergePreviewResult, + ): Promise { + const qx = SequelizeRepository.getQueryExecutor(this.options) - // mergeAction is found, unmerge preview will be generated - const primaryBackup = mergeAction.unmergeBackup.primary as IMemberUnmergeBackup - const secondaryBackup = mergeAction.unmergeBackup.secondary as IMemberUnmergeBackup + const { primary, secondary, movedIdentities } = await captureApiChange( + this.options, + memberUnmergeAction(memberId, async (captureOldState, captureNewState) => { + captureOldState({ primary: payload.primary }) - const remainingIdentitiesInCurrentMember = member.identities.filter( - (i) => - !secondaryBackup.identities.some( - (s) => s.platform === i.platform && s.value === i.value && s.type === i.type, - ), + const result = await qx.tx(async (tx) => + unmergeMember(tx, memberId, payload, this.options.currentUser?.id), ) - // Only unmerge when primary member still has some identities left after removing identities in the secondary backup - // if not fall back to identity extraction - if (remainingIdentitiesInCurrentMember.length > 0) { - // construct primary member with best effort - for (const key of MemberService.MEMBER_MERGE_FIELDS) { - // delay relationships for later - if (!(key in relationships) && !(member.manuallyChangedFields || []).includes(key)) { - if (key === 'attributes') { - // 1) if both primary and secondary backup have the attribute, check any platform specific value came from merge, if current member has it, revert it - // 2) if primary backup doesn't have the attribute, and secondary backup does, check if current member has the same value, if yes revert it (it came through merge) - // 3) if primary backup has the attribute, and secondary doesn't, keep the current value - // 4) if both backups doesn't have the value, but current member does, keep the current value - // we only need to act on cases 1 and 2, because we don't need to change current member's attributes for other cases - - // loop through current member attributes - for (const attributeKey of Object.keys(member.attributes)) { - if ( - !(member.manuallyChangedFields || []).some((f) => f === `attributes.${key}`) - ) { - // both backups have the attribute - if ( - primaryBackup.attributes[attributeKey] && - secondaryBackup.attributes[attributeKey] - ) { - // find platform key values that exist on secondary, but not on primary backup - const platformKeysThatOnlyExistOnSecondaryBackup = Object.keys( - secondaryBackup.attributes[attributeKey], - ).filter( - (key) => - primaryBackup.attributes[attributeKey][key] === null || - primaryBackup.attributes[attributeKey][key] === undefined || - primaryBackup.attributes[attributeKey][key] === '', - ) - - for (const platformKey of platformKeysThatOnlyExistOnSecondaryBackup) { - // check current member still has this value for the attribute[key][platform], and primary backup didn't have this value - if ( - member.attributes[attributeKey][platformKey] === - secondaryBackup.attributes[attributeKey][platformKey] && - primaryBackup.attributes[attributeKey][platformKey] !== - member.attributes[attributeKey][platformKey] - ) { - delete member.attributes[attributeKey][platformKey] - } - if (Object.keys(member.attributes[attributeKey]).length === 0) { - delete member.attributes[attributeKey] - } - } - } else if ( - !primaryBackup.attributes[attributeKey] && - secondaryBackup.attributes[attributeKey] - ) { - // remove platform keys that has the same value with current member - if (member.attributes[attributeKey]) { - for (const platformKey of Object.keys(member.attributes[attributeKey])) { - if ( - member.attributes[attributeKey][platformKey] === - secondaryBackup.attributes[attributeKey][platformKey] - ) { - delete member.attributes[attributeKey][platformKey] - } - } - - // check any platform keys remaining on current member, if not remove the attribute completely - if (Object.keys(member.attributes[attributeKey]).length === 0) { - delete member.attributes[attributeKey] - } - } - } - } - } - } else if (key === 'reach') { - // only act on reach if current member has some data - for (const reachKey of Object.keys(member.reach)) { - if ( - reachKey !== 'total' && - secondaryBackup.reach[reachKey] === member.reach[reachKey] - ) { - delete member.reach[reachKey] - } - } - // check if there are any keys other than total, if yes recalculate total, else set total to -1 - if (Object.keys(member.reach).length > 1) { - delete member.reach.total - member.reach.total = lodash.sum(Object.values(member.reach)) - } else { - member.reach.total = -1 - } - } else if (key === 'contributions') { - // check secondary member has any contributions to extract from current member - if (member.contributions && Array.isArray(member.contributions)) { - const secondaryContributions = Array.isArray(secondaryBackup.contributions) - ? secondaryBackup.contributions - : [] - member.contributions = member.contributions.filter( - (c) => !secondaryContributions.some((s) => s.id === c.id), - ) - } - } else if ( - primaryBackup[key] !== member[key] && - secondaryBackup[key] === member[key] - ) { - member[key] = null - } - } - } - - // identities: Remove identities coming from secondary backup - member.identities = member.identities.filter( - (i) => - !secondaryBackup.identities.some( - (s) => s.platform === i.platform && s.value === i.value && s.type === i.type, - ), - ) - - // affiliations: Remove affiliations coming from secondary backup - member.affiliations = member.affiliations.filter( - (a) => !secondaryBackup.affiliations.some((s) => s.id === a.id), - ) - - // member organizations - const unmergedRoles = MemberOrganizationService.unmergeRoles( - member.memberOrganizations, - primaryBackup.memberOrganizations, - secondaryBackup.memberOrganizations, - MemberRoleUnmergeStrategy.SAME_MEMBER, - ) - member.memberOrganizations = unmergedRoles as IMemberRoleWithOrganization[] - - return { - primary: { - ...lodash.pick(member, MemberService.MEMBER_MERGE_FIELDS), - identities: member.identities, - memberOrganizations: member.memberOrganizations, - organizations: OrganizationRepository.calculateRenderFriendlyOrganizations( - member.memberOrganizations, - ), - username: MemberRepository.getUsernameFromIdentities(member.identities), - numberOfOpenSourceContributions: member.contributions?.length || 0, - }, - secondary: { - ...secondaryBackup, - organizations: OrganizationRepository.calculateRenderFriendlyOrganizations( - secondaryBackup.memberOrganizations, - ), - numberOfOpenSourceContributions: secondaryBackup.contributions?.length || 0, - }, - } - } - } - - // Identity extraction preview will be generated if revertMerge flag is not set - let secondaryIdentities = [identity] - - // For email identities, extract all related identities across sources - if (identity.type === MemberIdentityType.EMAIL) { - const allEmailIdentities = await findMemberIdentitiesByValue(qx, memberId, identity.value, { - type: MemberIdentityType.EMAIL, + captureNewState({ + primary: result.primary, + secondary: result.secondary, }) - // exclude the original identity to avoid duplicates - secondaryIdentities = lodash.uniqBy([...allEmailIdentities, identity], (i) => i.id) - } - - // Ensure primary member retains at least one identity - const primaryIdentities = member.identities.filter( - (i) => - !secondaryIdentities.some( - (s) => s.platform === i.platform && s.value === i.value && s.type === i.type, - ), - ) + return result + }), + ) - if (primaryIdentities.length === 0) { - throw new Error400(this.options.language, 'merge.errors.noIdentities') - } + await invalidateMemberQueryCache(this.options.redis, [primary.id, secondary.id], true) - const primaryMemberRolesMap = await fetchManyMemberOrgsWithOrgData(qx, [member.id]) - const primaryMemberRoles = primaryMemberRolesMap.get(member.id) - - return { - primary: { - ...lodash.pick(member, MemberService.MEMBER_MERGE_FIELDS), - identities: primaryIdentities, - memberOrganizations: primaryMemberRoles, - organizations: - OrganizationRepository.calculateRenderFriendlyOrganizations(primaryMemberRoles), - username: MemberRepository.getUsernameFromIdentities(primaryIdentities), - numberOfOpenSourceContributions: member.contributions?.length || 0, - }, - secondary: { - id: randomUUID(), - reach: { total: -1 }, - username: MemberRepository.getUsernameFromIdentities(secondaryIdentities), - displayName: getProperDisplayName(identity.value), - identities: secondaryIdentities, - memberOrganizations: [], - organizations: [], - attributes: {}, - joinedAt: new Date().toISOString(), - tenantId: member.tenantId, - affiliations: [], - contributions: [], - manuallyCreated: true, - manuallyChangedFields: [], - numberOfOpenSourceContributions: 0, - }, - } - } catch (err) { - this.options.log.error(err, 'Error while generating unmerge/identity extraction preview!') - throw err - } + await startMemberUnmergeWorkflow(this.options.temporal, { + primaryId: primary.id, + secondaryId: secondary.id, + movedIdentities, + primaryDisplayName: primary.displayName, + secondaryDisplayName: secondary.displayName, + actorId: this.options.currentUser?.id, + }) } async canRevertMerge(memberId: string, identityId: string): Promise { try { const qx = SequelizeRepository.getQueryExecutor(this.options) - // Get the identity to be unmerged const identity = await findMemberIdentityById(qx, memberId, identityId) + if (!identity) { - throw new Error400(this.options.language, 'merge.errors.identityMismatch') + throw new Error(`Member doesn't have an identity with id ${identityId}!`) } - // Check if there was a previous merge involving this identity const mergeAction = await MergeActionsRepository.findMergeBackup( memberId, MergeActionType.MEMBER, @@ -1111,16 +659,15 @@ export default class MemberService extends LoggerBase { this.options, ) - const previousMergeExists = !!mergeAction - - if (!previousMergeExists) { + if (!mergeAction) { return false } - // Check if the primary member would still have identities after reverting const secondaryBackup = mergeAction.unmergeBackup.secondary as IMemberUnmergeBackup - const currentMemberIdentities = await fetchMemberIdentities(qx, memberId) - const remainingIdentitiesInCurrentMember = currentMemberIdentities.filter( + + const memberIdentities = await fetchMemberIdentities(qx, memberId) + + const remainingIdentitiesInCurrentMember = memberIdentities.filter( (i) => !secondaryBackup.identities.some( (s) => s.platform === i.platform && s.value === i.value && s.type === i.type, @@ -1271,7 +818,7 @@ export default class MemberService extends LoggerBase { // Invalidate member query cache after update // Pass invalidateCache from options to control whether to clear list caches - await this.invalidateMemberQueryCache([id], invalidateCache) + await invalidateMemberQueryCache(this.options.redis, [id], invalidateCache) const commonMemberService = new CommonMemberService( optionsQx(this.options), @@ -1309,13 +856,19 @@ export default class MemberService extends LoggerBase { } } - async destroyBulk(ids) { + async destroyBulk(ids: unknown) { + const normalizedIds: string[] = MemberService.normalizeIds(ids) + + if (normalizedIds.length === 0) { + return + } + const transaction = await SequelizeRepository.createTransaction(this.options) const searchSyncService = new SearchSyncService(this.options) try { await MemberRepository.destroyBulk( - ids, + normalizedIds, { ...this.options, transaction, @@ -1327,23 +880,29 @@ export default class MemberService extends LoggerBase { // Invalidate member query cache after bulk delete // Pass invalidateAll=true to also clear list caches since deletion affects list views - await this.invalidateMemberQueryCache(ids, true) + await invalidateMemberQueryCache(this.options.redis, normalizedIds, true) } catch (error) { await SequelizeRepository.rollbackTransaction(transaction) throw error } - for (const id of ids) { + for (const id of normalizedIds) { await searchSyncService.triggerRemoveMember(id) } } - async destroyAll(ids) { + async destroyAll(ids: unknown) { + const normalizedIds: string[] = MemberService.normalizeIds(ids) + + if (normalizedIds.length === 0) { + return + } + const transaction = await SequelizeRepository.createTransaction(this.options) const searchSyncService = new SearchSyncService(this.options) try { - for (const id of ids) { + for (const id of normalizedIds) { await MemberRepository.destroy( id, { @@ -1358,13 +917,13 @@ export default class MemberService extends LoggerBase { // Invalidate member query cache after deletion // Pass invalidateAll=true to also clear list caches since deletion affects list views - await this.invalidateMemberQueryCache(ids, true) + await invalidateMemberQueryCache(this.options.redis, normalizedIds, true) } catch (error) { await SequelizeRepository.rollbackTransaction(transaction) throw error } - for (const id of ids) { + for (const id of normalizedIds) { await searchSyncService.triggerRemoveMember(id) } } @@ -1486,32 +1045,4 @@ export default class MemberService extends LoggerBase { const qx = SequelizeRepository.getQueryExecutor(this.options) return fetchMemberBotSuggestionsBySegment(qx, segmentId, args.limit ?? 10, args.offset ?? 0) } - - async invalidateMemberQueryCache(memberIds?: string[], invalidateAll = false): Promise { - try { - const cache = new MemberQueryCache(this.options.redis) - - if (memberIds && memberIds.length > 0) { - // Invalidate specific member cache entries (queries with filter.id.eq) - for (const memberId of memberIds) { - await cache.invalidateByPattern(`members_advanced:${memberId}:*`) - } - this.log.debug(`Invalidated member query cache for ${memberIds.length} specific members`) - - // Only invalidate all caches if explicitly requested - // This is useful for operations like update/delete that affect list views - if (invalidateAll) { - await cache.invalidateAll() - this.log.debug('Invalidated all member query cache entries') - } - } else { - // Invalidate all cache entries - await cache.invalidateAll() - this.log.debug('Invalidated all member query cache') - } - } catch (error) { - // Don't fail the operation if cache invalidation fails - this.log.warn('Failed to invalidate member query cache', { error }) - } - } } diff --git a/backend/src/services/organizationService.ts b/backend/src/services/organizationService.ts index 7e557789d9..259a8a8735 100644 --- a/backend/src/services/organizationService.ts +++ b/backend/src/services/organizationService.ts @@ -7,6 +7,7 @@ import { organizationUnmergeAction, } from '@crowd/audit-logs' import { Error400, Error404, Error409, mergeObjects, normalizeHostname } from '@crowd/common' +import { unmergeRoles } from '@crowd/common_services' import { addMemberRole, moveMembersBetweenOrganizations, @@ -14,7 +15,7 @@ import { removeMemberRole, } from '@crowd/data-access-layer' import { hasLfxMembership } from '@crowd/data-access-layer/src/lfx_memberships' -import { applyOrganizationAffiliationPolicyToMembers } from '@crowd/data-access-layer/src/member_organization_affiliation_overrides' +import { applyOrganizationAffiliationPolicyToMembers } from '@crowd/data-access-layer/src/member-organization-affiliation' import { addMergeAction, queryMergeActions, @@ -62,7 +63,6 @@ import { keepPrimaryIfExists, mergeUniqueStringArrayItems, } from './helpers/mergeFunctions' -import MemberOrganizationService from './memberOrganizationService' import SearchSyncService from './searchSyncService' export default class OrganizationService extends LoggerBase { @@ -368,7 +368,7 @@ export default class OrganizationService extends LoggerBase { repoOptions, ) - const primaryUnmergedRoles = await MemberOrganizationService.unmergeRoles( + const primaryUnmergedRoles = await unmergeRoles( memberOrganizations, mergeAction.unmergeBackup.primary.memberOrganizations, mergeAction.unmergeBackup.secondary.memberOrganizations, diff --git a/backend/src/services/segmentService.ts b/backend/src/services/segmentService.ts index 9f4656ab80..aa32044f5a 100644 --- a/backend/src/services/segmentService.ts +++ b/backend/src/services/segmentService.ts @@ -7,7 +7,7 @@ import { updateOrganization, } from '@crowd/data-access-layer' import { ICreateInsightsProject, findBySlug } from '@crowd/data-access-layer/src/collections' -import { applyOrganizationAffiliationPolicyToMembers } from '@crowd/data-access-layer/src/member_organization_affiliation_overrides' +import { applyOrganizationAffiliationPolicyToMembers } from '@crowd/data-access-layer/src/member-organization-affiliation' import { buildSegmentActivityTypes, isSegmentSubproject, diff --git a/backend/src/types/api.ts b/backend/src/types/api.ts index 1214f9798c..7d03c540c0 100644 --- a/backend/src/types/api.ts +++ b/backend/src/types/api.ts @@ -1,4 +1,3 @@ -import type { Request } from 'express' import type { JWTPayload } from 'express-oauth2-jwt-bearer' /** @@ -19,11 +18,3 @@ export interface Actor { id: string scopes: string[] } - -/** - * Express request with authenticated actor - * Use req.actor to check identity and permissions - */ -export interface ApiRequest extends Request { - actor: Actor -} diff --git a/backend/src/types/express.d.ts b/backend/src/types/express.d.ts new file mode 100644 index 0000000000..c707a39653 --- /dev/null +++ b/backend/src/types/express.d.ts @@ -0,0 +1,18 @@ +import type { Logger } from '@crowd/logging' +import type { RedisClient } from '@crowd/redis' +import type { Client as TemporalClient } from '@crowd/temporal' + +import type ApiResponseHandler from '@/api/apiResponseHandler' +import type { Actor } from '@/types/api' + +declare global { + namespace Express { + interface Request { + actor: Actor + redis: RedisClient + temporal: TemporalClient + log: Logger + responseHandler: ApiResponseHandler + } + } +} diff --git a/backend/src/utils/mapper.ts b/backend/src/utils/mapper.ts new file mode 100644 index 0000000000..29195816a8 --- /dev/null +++ b/backend/src/utils/mapper.ts @@ -0,0 +1,17 @@ +import type { IMemberRoleWithOrganization } from '@crowd/types' + +export function toMemberWorkExperience(mo: IMemberRoleWithOrganization) { + return { + id: mo.id, + organizationId: mo.organizationId, + organizationName: mo.organizationName, + organizationLogo: mo.organizationLogo, + jobTitle: mo.title ?? null, + verified: mo.verified ?? false, + source: mo.source ?? null, + startDate: mo.dateStart ?? null, + endDate: mo.dateEnd ?? null, + createdAt: mo.createdAt ?? null, + updatedAt: mo.updatedAt ?? null, + } +} diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts new file mode 100644 index 0000000000..bc58a09daa --- /dev/null +++ b/backend/src/utils/validation.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' + +import { BadRequestError } from '@crowd/common' + +export function validateOrThrow(schema: T, data: unknown): z.infer { + const result = schema.safeParse(data) + + if (!result.success) { + const messages = result.error.issues.map((issue) => { + const path = issue.path.length ? `${issue.path.join('.')}: ` : '' + return `${path}${issue.message}` + }) + throw new BadRequestError(messages.join('; ')) + } + + return result.data +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json index f3405cdcaf..d4a6bcb467 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -10,7 +10,7 @@ "noUnusedParameters": false, "sourceMap": true, "target": "es2018", - "types": ["node"], + "types": ["node", "express"], "baseUrl": "./src", "paths": { "@/*": ["./*"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb972322e9..052cfd6ade 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -351,6 +351,9 @@ importers: zlib-sync: specifier: ^0.1.8 version: 0.1.9 + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@babel/core': specifier: ^7.24.4 @@ -376,6 +379,9 @@ importers: '@types/cron': specifier: ^2.0.0 version: 2.4.0 + '@types/express': + specifier: ^4.17.17 + version: 4.17.21 '@types/html-to-text': specifier: ^8.1.1 version: 8.1.1 @@ -1950,6 +1956,9 @@ importers: lodash.pick: specifier: ~4.4.0 version: 4.4.0 + lodash.uniqby: + specifier: ^4.7.0 + version: 4.7.0 moment: specifier: ^2.30.1 version: 2.30.1 @@ -10251,6 +10260,9 @@ packages: zlib-sync@0.1.9: resolution: {integrity: sha512-DinB43xCjVwIBDpaIvQqHbmDsnYnSt6HJ/yiB2MZQGTqgPcwBSZqLkimXwK8BvdjQ/MaZysb5uEenImncqvCqQ==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + snapshots: '@actions/core@1.10.1': @@ -10366,8 +10378,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.572.0 - '@aws-sdk/client-sts': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0) + '@aws-sdk/client-sso-oidc': 3.572.0(@aws-sdk/client-sts@3.572.0) + '@aws-sdk/client-sts': 3.572.0 '@aws-sdk/core': 3.572.0 '@aws-sdk/credential-provider-node': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0)(@aws-sdk/client-sts@3.572.0) '@aws-sdk/middleware-host-header': 3.567.0 @@ -10561,11 +10573,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.572.0': + '@aws-sdk/client-sso-oidc@3.572.0(@aws-sdk/client-sts@3.572.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0) + '@aws-sdk/client-sts': 3.572.0 '@aws-sdk/core': 3.572.0 '@aws-sdk/credential-provider-node': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0)(@aws-sdk/client-sts@3.572.0) '@aws-sdk/middleware-host-header': 3.567.0 @@ -10604,6 +10616,7 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.6.2 transitivePeerDependencies: + - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso@3.556.0': @@ -10779,11 +10792,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.572.0(@aws-sdk/client-sso-oidc@3.572.0)': + '@aws-sdk/client-sts@3.572.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.572.0 + '@aws-sdk/client-sso-oidc': 3.572.0(@aws-sdk/client-sts@3.572.0) '@aws-sdk/core': 3.572.0 '@aws-sdk/credential-provider-node': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0)(@aws-sdk/client-sts@3.572.0) '@aws-sdk/middleware-host-header': 3.567.0 @@ -10822,7 +10835,6 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.6.2 transitivePeerDependencies: - - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.985.0': @@ -10988,7 +11000,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.572.0(@aws-sdk/client-sso-oidc@3.572.0)(@aws-sdk/client-sts@3.572.0)': dependencies: - '@aws-sdk/client-sts': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0) + '@aws-sdk/client-sts': 3.572.0 '@aws-sdk/credential-provider-env': 3.568.0 '@aws-sdk/credential-provider-process': 3.572.0 '@aws-sdk/credential-provider-sso': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0) @@ -11165,7 +11177,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.568.0(@aws-sdk/client-sts@3.572.0)': dependencies: - '@aws-sdk/client-sts': 3.572.0(@aws-sdk/client-sso-oidc@3.572.0) + '@aws-sdk/client-sts': 3.572.0 '@aws-sdk/types': 3.567.0 '@smithy/property-provider': 2.2.0 '@smithy/types': 2.12.0 @@ -11477,7 +11489,7 @@ snapshots: '@aws-sdk/token-providers@3.572.0(@aws-sdk/client-sso-oidc@3.572.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.572.0 + '@aws-sdk/client-sso-oidc': 3.572.0(@aws-sdk/client-sts@3.572.0) '@aws-sdk/types': 3.567.0 '@smithy/property-provider': 2.2.0 '@smithy/shared-ini-file-loader': 2.4.0 @@ -20572,3 +20584,5 @@ snapshots: zlib-sync@0.1.9: dependencies: nan: 2.19.0 + + zod@4.3.6: {} diff --git a/services/apps/data_sink_worker/src/service/member.service.ts b/services/apps/data_sink_worker/src/service/member.service.ts index 4dfb0afd37..73970672f3 100644 --- a/services/apps/data_sink_worker/src/service/member.service.ts +++ b/services/apps/data_sink_worker/src/service/member.service.ts @@ -125,13 +125,12 @@ export default class MemberService extends LoggerBase { } } - const id = await logExecutionTimeV2( + const { id } = await logExecutionTimeV2( () => createMember(this.pgQx, { displayName: data.displayName, joinedAt: data.joinedAt.toISOString(), attributes, - identities: data.identities, reach: MemberService.calculateReach({}, data.reach), }), this.log, diff --git a/services/apps/data_sink_worker/src/service/organization.service.ts b/services/apps/data_sink_worker/src/service/organization.service.ts index 68e9dc80fe..4908cf498f 100644 --- a/services/apps/data_sink_worker/src/service/organization.service.ts +++ b/services/apps/data_sink_worker/src/service/organization.service.ts @@ -1,6 +1,8 @@ -import { checkOrganizationAffiliationPolicy } from '@crowd/data-access-layer' +import { + changeMemberOrganizationAffiliationOverrides, + checkOrganizationAffiliationPolicy, +} from '@crowd/data-access-layer' import { DbStore } from '@crowd/data-access-layer/src/database' -import { changeMemberOrganizationAffiliationOverrides } from '@crowd/data-access-layer/src/member_organization_affiliation_overrides' import { addOrgsToMember, addOrgsToSegments, diff --git a/services/apps/members_enrichment_worker/src/activities/enrichment.ts b/services/apps/members_enrichment_worker/src/activities/enrichment.ts index e50adc0b3d..b1855002c6 100644 --- a/services/apps/members_enrichment_worker/src/activities/enrichment.ts +++ b/services/apps/members_enrichment_worker/src/activities/enrichment.ts @@ -9,6 +9,7 @@ import { setAttributesDefaultValues, } from '@crowd/common' import { + changeMemberOrganizationAffiliationOverrides, checkOrganizationAffiliationPolicy, updateMemberAttributes, updateMemberContributions, @@ -16,7 +17,6 @@ import { } from '@crowd/data-access-layer' import { createMemberIdentity } from '@crowd/data-access-layer' import { findMemberIdentityWithTheMostActivityInPlatform as getMemberMostActiveIdentity } from '@crowd/data-access-layer/src/activityRelations' -import { changeMemberOrganizationAffiliationOverrides } from '@crowd/data-access-layer/src/member_organization_affiliation_overrides' import { getPlatformPriorityArray } from '@crowd/data-access-layer/src/members/attributeSettings' import { deleteMemberOrgById, diff --git a/services/apps/script_executor_worker/src/activities/block-project-organization-affiliations.ts b/services/apps/script_executor_worker/src/activities/block-project-organization-affiliations.ts index a92b7da242..9cc56043f8 100644 --- a/services/apps/script_executor_worker/src/activities/block-project-organization-affiliations.ts +++ b/services/apps/script_executor_worker/src/activities/block-project-organization-affiliations.ts @@ -1,5 +1,5 @@ import { OrganizationField, findOrgById, pgpQx, updateOrganization } from '@crowd/data-access-layer' -import { changeMemberOrganizationAffiliationOverrides } from '@crowd/data-access-layer/src/member_organization_affiliation_overrides' +import { changeMemberOrganizationAffiliationOverrides } from '@crowd/data-access-layer/src/member-organization-affiliation' import OrganizationRepository from '@crowd/data-access-layer/src/old/apps/script_executor_worker/organization.repo' import { IChangeAffiliationOverrideData, IMemberOrganization } from '@crowd/types' diff --git a/services/apps/script_executor_worker/src/activities/common.ts b/services/apps/script_executor_worker/src/activities/common.ts index 114d82d294..e970bf8a4d 100644 --- a/services/apps/script_executor_worker/src/activities/common.ts +++ b/services/apps/script_executor_worker/src/activities/common.ts @@ -38,7 +38,7 @@ export async function mergeMembers( export async function unmergeMembers( primaryMemberId: string, - backup: IUnmergeBackup, + backup: IUnmergeBackup | IUnmergePreviewResult, ): Promise { const url = `${process.env['CROWD_API_SERVICE_URL']}/member/${primaryMemberId}/unmerge` const requestOptions = { diff --git a/services/libs/audit-logs/src/actions.ts b/services/libs/audit-logs/src/actions.ts index 1656e77f22..0de11dbafc 100644 --- a/services/libs/audit-logs/src/actions.ts +++ b/services/libs/audit-logs/src/actions.ts @@ -88,6 +88,20 @@ export function memberEditProfileAction( return modifyEntityAction(ActionType.MEMBERS_EDIT_PROFILE, entityId, captureFn) } +export function memberVerifyIdentityAction( + entityId: string, + captureFn: CaptureFn, +): BuildActionFn { + return modifyEntityAction(ActionType.MEMBERS_VERIFY_IDENTITY, entityId, captureFn) +} + +export function memberVerifyWorkExperienceAction( + entityId: string, + captureFn: CaptureFn, +): BuildActionFn { + return modifyEntityAction(ActionType.MEMBERS_VERIFY_WORK_EXPERIENCE, entityId, captureFn) +} + export function memberMergeAction(entityId: string, captureFn: CaptureFn): BuildActionFn { return modifyEntityAction(ActionType.MEMBERS_MERGE, entityId, captureFn) } diff --git a/services/libs/common_services/package.json b/services/libs/common_services/package.json index 577b65ffba..e6b22b8fff 100644 --- a/services/libs/common_services/package.json +++ b/services/libs/common_services/package.json @@ -20,8 +20,8 @@ "@crowd/integrations": "workspace:*", "@crowd/logging": "workspace:*", "@crowd/queue": "workspace:*", - "@crowd/temporal": "workspace:*", "@crowd/redis": "workspace:*", + "@crowd/temporal": "workspace:*", "@crowd/types": "workspace:*", "@octokit/request": "^5.6.3", "@octokit/rest": "^22.0.0", @@ -29,6 +29,7 @@ "jsonwebtoken": "^9.0.0", "lodash.isequal": "^4.5.0", "lodash.pick": "~4.4.0", + "lodash.uniqby": "^4.7.0", "moment": "^2.30.1" } } diff --git a/services/libs/common_services/src/services/common.member.service.ts b/services/libs/common_services/src/services/common.member.service.ts index 1304a0592d..70c70e0647 100644 --- a/services/libs/common_services/src/services/common.member.service.ts +++ b/services/libs/common_services/src/services/common.member.service.ts @@ -20,6 +20,7 @@ import { MEMBER_MERGE_FIELDS, MemberField, QueryExecutor, + changeMemberOrganizationAffiliationOverrides, checkOrganizationAffiliationPolicy, createOrUpdateMemberOrganizations, deleteMemberOrganizations, @@ -40,7 +41,6 @@ import { updateMember, } from '@crowd/data-access-layer' import { removeMemberToMerge } from '@crowd/data-access-layer/src/member_merge' -import { changeMemberOrganizationAffiliationOverrides } from '@crowd/data-access-layer/src/member_organization_affiliation_overrides' import { findMemberAffiliations } from '@crowd/data-access-layer/src/member_segment_affiliations' import { addMergeAction, diff --git a/services/libs/common_services/src/services/index.ts b/services/libs/common_services/src/services/index.ts index 2650c89268..227ef64e34 100644 --- a/services/libs/common_services/src/services/index.ts +++ b/services/libs/common_services/src/services/index.ts @@ -1,6 +1,9 @@ export * from './priority.service' export * from './llm.service' export * from './common.member.service' +export * from './member/unmerge' +export * from './member/cache' +export * from './memberOrganization' export * from './bot.service' export * from './emitters' export * from './github.integration.service' diff --git a/services/libs/common_services/src/services/member/cache.ts b/services/libs/common_services/src/services/member/cache.ts new file mode 100644 index 0000000000..f3c0a650fe --- /dev/null +++ b/services/libs/common_services/src/services/member/cache.ts @@ -0,0 +1,44 @@ +import { MemberQueryCache } from '@crowd/data-access-layer' +import { getServiceLogger } from '@crowd/logging' +import { RedisClient } from '@crowd/redis' + +const logger = getServiceLogger() + +export async function invalidateMemberQueryCache( + redis: RedisClient, + memberIds?: string[], + invalidateAll = false, +): Promise { + try { + const cache = new MemberQueryCache(redis) + + // Normalize memberIds to a validated array of strings + const validMemberIds: string[] = Array.isArray(memberIds) + ? memberIds.filter((id): id is string => typeof id === 'string' && id.length > 0) + : [] + + if (validMemberIds.length > 0) { + // Invalidate specific member cache entries (queries with filter.id.eq) + for (const memberId of validMemberIds) { + await cache.invalidateByPattern(`members_advanced:${memberId}:*`) + } + + logger.debug(`Invalidated member query cache for ${validMemberIds.length} specific members`) + + // Only invalidate all caches if explicitly requested + // This is useful for operations like update/delete that affect list views + if (invalidateAll) { + await cache.invalidateAll() + logger.debug('Invalidated all member query cache entries') + } + } else if (invalidateAll) { + // No valid member IDs but invalidateAll requested - invalidate all cache entries + await cache.invalidateAll() + logger.debug('Invalidated all member query cache') + } + // If no valid member IDs and invalidateAll is false, skip invalidation entirely + } catch (error) { + // Don't fail the operation if cache invalidation fails + logger.warn('Failed to invalidate member query cache', { error }) + } +} diff --git a/services/libs/common_services/src/services/member/unmerge.ts b/services/libs/common_services/src/services/member/unmerge.ts new file mode 100644 index 0000000000..b07b21a3dd --- /dev/null +++ b/services/libs/common_services/src/services/member/unmerge.ts @@ -0,0 +1,638 @@ +import { randomUUID } from 'crypto' +import pick from 'lodash.pick' +import uniqBy from 'lodash.uniqby' + +import { BadRequestError, DEFAULT_TENANT_ID, getProperDisplayName } from '@crowd/common' +import { + MEMBER_MERGE_FIELDS, + MemberField, + QueryExecutor, + addMemberRole, + createMember, + createMemberIdentity, + deleteManyMemberIdentities, + fetchManyMemberOrgsWithOrgData, + fetchMemberIdentities, + findAlreadyExistingVerifiedIdentities, + findMemberById, + findNonExistingOrganizationIds, + removeMemberRole, + updateMember, +} from '@crowd/data-access-layer' +import { addMemberNoMerge } from '@crowd/data-access-layer/src/member_merge' +import { + findMemberAffiliations, + moveSelectedAffiliationsBetweenMembers, +} from '@crowd/data-access-layer/src/member_segment_affiliations' +import { MemberUpdateInput } from '@crowd/data-access-layer/src/members/base' +import { + findMemberIdentitiesByValue, + findMemberIdentityById, +} from '@crowd/data-access-layer/src/members/identities' +import { + addMergeAction, + findMergeBackup, + setMergeAction, +} from '@crowd/data-access-layer/src/mergeActions/repo' +import { getServiceLogger } from '@crowd/logging' +import { Client as TemporalClient } from '@crowd/temporal' +import { + IAttributes, + IMemberAffiliationMergeBackup, + IMemberContribution, + IMemberIdentity, + IMemberRenderFriendlyRole, + IMemberRoleWithOrganization, + IMemberUnmergeBackup, + IMemberUnmergePreviewResult, + IMemberUsername, + IUnmergePreviewResult, + MemberBotDetection, + MemberIdentityType, + MemberRoleUnmergeStrategy, + MemberRow, + MemberUnmergeResult, + MergeActionState, + MergeActionStep, + MergeActionType, +} from '@crowd/types' + +import { BotDetectionService } from '../bot.service' +import { unmergeRoles } from '../memberOrganization' + +const logger = getServiceLogger() + +/** + * Revert only attributes that came through the merge, leaving other attributes intact. + * + * 1) Both primary and secondary backup have the attribute → remove platform keys that came from secondary + * 2) Only secondary backup has the attribute → remove platform keys matching secondary's values + * 3) Only primary backup has the attribute → keep current value (nothing to revert) + * 4) Neither backup has the attribute → keep current value + * + * We only act on cases 1 and 2. + */ +function revertAttributes( + current: IAttributes, + primaryBackup: IAttributes, + secondaryBackup: IAttributes, + manualFields: string[], +): IAttributes { + const result: IAttributes = {} + + for (const attributeKey of Object.keys(current)) { + if (manualFields.some((f) => f === `attributes.${attributeKey}`)) { + result[attributeKey] = { ...current[attributeKey] } + continue + } + + const pAttr = primaryBackup[attributeKey] + const sAttr = secondaryBackup[attributeKey] + + // both backups have the attribute + if (pAttr && sAttr) { + const cleaned = { ...current[attributeKey] } + // find platform key values that exist on secondary, but not on primary backup + for (const platformKey of Object.keys(sAttr)) { + if (pAttr[platformKey] == null || pAttr[platformKey] === '') { + // check current member still has this value for the attribute[key][platform], and primary backup didn't have this value + if ( + cleaned[platformKey] === sAttr[platformKey] && + pAttr[platformKey] !== cleaned[platformKey] + ) { + delete cleaned[platformKey] + } + } + } + // check any platform keys remaining on current member, if not remove the attribute completely + if (Object.keys(cleaned).length > 0) { + result[attributeKey] = cleaned + } + } else if (!pAttr && sAttr && current[attributeKey]) { + // remove platform keys that have the same value with current member + const cleaned = { ...current[attributeKey] } + for (const platformKey of Object.keys(cleaned)) { + if (cleaned[platformKey] === sAttr[platformKey]) { + delete cleaned[platformKey] + } + } + // check any platform keys remaining on current member, if not remove the attribute completely + if (Object.keys(cleaned).length > 0) { + result[attributeKey] = cleaned + } + } else { + result[attributeKey] = { ...current[attributeKey] } + } + } + + return result +} + +// only act on reach if current member has some data +function revertReach( + current: Record, + secondaryBackup: Record, +): Record { + const result: Record = {} + for (const [key, value] of Object.entries(current)) { + if (key !== 'total' && secondaryBackup[key] === value) continue + if (key !== 'total') result[key] = value + } + // check if there are any keys other than total, if yes recalculate total, else set total to -1 + const values = Object.values(result) + result.total = values.length > 0 ? values.reduce((a, b) => a + b, 0) : -1 + return result +} + +function getUsernameFromIdentities(identities: IMemberIdentity[]): IMemberUsername { + const username: IMemberUsername = {} + for (const i of identities) { + if (i.type !== MemberIdentityType.USERNAME) continue + if (username[i.platform]) { + ;(username[i.platform] as string[]).push(i.value) + } else { + username[i.platform] = [i.value] + } + } + return username +} + +export function calculateRenderFriendlyOrganizations( + roles: IMemberRoleWithOrganization[], +): IMemberRenderFriendlyRole[] { + return roles.map((role) => ({ + id: role.organizationId, + displayName: role.organizationName, + logo: role.organizationLogo, + memberOrganizations: role, + })) +} + +/** + * Build a preview of the unmerge outcome without side effects. + * + * When `revertPreviousMerge` is true, restores both members from stored + * merge backups. Otherwise extracts the selected identity into a new + * member. Manual fields are always preserved. + */ +export async function prepareMemberUnmerge( + qx: QueryExecutor, + memberId: string, + identityId: string, + revertPreviousMerge = false, +): Promise> { + const memberById = await findMemberById(qx, memberId, [ + MemberField.ID, + MemberField.DISPLAY_NAME, + MemberField.ATTRIBUTES, + MemberField.REACH, + MemberField.CONTRIBUTIONS, + MemberField.MANUALLY_CHANGED_FIELDS, + ]) + + if (!memberById) { + throw new BadRequestError(`Member ${memberId} not found`) + } + + logger.info({ memberId }, 'Fetching member relations (identities, affiliations, organizations)') + + const [memberOrganizationsMap, identities, affiliations] = await Promise.all([ + fetchManyMemberOrgsWithOrgData(qx, [memberId]), + fetchMemberIdentities(qx, memberId), + findMemberAffiliations(qx, memberId), + ]) + + logger.info({ memberId }, 'Done fetching member relations') + + const memberOrganizations = memberOrganizationsMap.get(memberId) ?? [] + + const member = { + ...memberById, + memberOrganizations, + identities, + affiliations, + } + + const identity = await findMemberIdentityById(qx, memberId, identityId) + + if (!identity) { + throw new BadRequestError('Identity not found or does not belong to this member') + } + + if (revertPreviousMerge) { + logger.info({ memberId }, 'Finding merge backup for revert') + + const mergeAction = await findMergeBackup(qx, memberId, MergeActionType.MEMBER, identity) + + if (!mergeAction) { + throw new BadRequestError('No previous merge action found to revert for member!') + } + + logger.info({ memberId }, 'Merge backup found, generating unmerge preview') + const primaryBackup = mergeAction.unmergeBackup.primary as IMemberUnmergeBackup + const secondaryBackup = mergeAction.unmergeBackup.secondary as IMemberUnmergeBackup + + const remainingIdentitiesInCurrentMember = member.identities.filter( + (i: IMemberIdentity) => + !secondaryBackup.identities.some( + (s) => s.platform === i.platform && s.value === i.value && s.type === i.type, + ), + ) + + // Only unmerge when primary member still has some identities left after removing identities in the secondary backup + // if not fall back to identity extraction + if (remainingIdentitiesInCurrentMember.length > 0) { + // construct primary member with best effort + const manualFields = member.manuallyChangedFields ?? [] + + const revertedAttributes = manualFields.includes('attributes') + ? member.attributes + : revertAttributes( + member.attributes ?? {}, + primaryBackup.attributes ?? {}, + secondaryBackup.attributes ?? {}, + manualFields, + ) + + const revertedReach = manualFields.includes('reach') + ? member.reach + : revertReach(member.reach ?? { total: -1 }, secondaryBackup.reach ?? {}) + + // check secondary member has any contributions to extract from current member + const secondaryContributions = Array.isArray(secondaryBackup.contributions) + ? secondaryBackup.contributions + : [] + + const revertedContributions = + manualFields.includes('contributions') || !Array.isArray(member.contributions) + ? member.contributions + : member.contributions.filter((c) => !secondaryContributions.some((s) => s.id === c.id)) + + // If displayName came from the secondary member through merge, revert it + const revertedDisplayName = + !manualFields.includes('displayName') && + primaryBackup.displayName !== member.displayName && + secondaryBackup.displayName === member.displayName + ? null + : member.displayName + + // identities: Remove identities coming from secondary backup + const revertedIdentities = member.identities.filter( + (i: IMemberIdentity) => + !secondaryBackup.identities.some( + (s) => s.platform === i.platform && s.value === i.value && s.type === i.type, + ), + ) + + // affiliations: Remove affiliations coming from secondary backup + const revertedAffiliations = member.affiliations.filter( + (a) => !secondaryBackup.affiliations.some((s) => s.id === a.id), + ) + + // member organizations + const unmergedRoles = unmergeRoles( + member.memberOrganizations, + primaryBackup.memberOrganizations, + secondaryBackup.memberOrganizations, + MemberRoleUnmergeStrategy.SAME_MEMBER, + ) as IMemberRoleWithOrganization[] + + return { + primary: { + ...pick(member, MEMBER_MERGE_FIELDS), + displayName: revertedDisplayName, + attributes: revertedAttributes, + reach: revertedReach, + contributions: revertedContributions, + identities: revertedIdentities, + memberOrganizations: unmergedRoles, + affiliations: revertedAffiliations, + organizations: calculateRenderFriendlyOrganizations(unmergedRoles), + username: getUsernameFromIdentities(revertedIdentities), + numberOfOpenSourceContributions: revertedContributions?.length ?? 0, + }, + secondary: { + ...secondaryBackup, + organizations: calculateRenderFriendlyOrganizations(secondaryBackup.memberOrganizations), + numberOfOpenSourceContributions: secondaryBackup.contributions?.length ?? 0, + }, + } + } + } + + // Identity extraction preview will be generated if revertMerge flag is not set + let secondaryIdentities: IMemberIdentity[] = [identity] + + // For email identities, extract all related identities across sources + if (identity.type === MemberIdentityType.EMAIL) { + const allEmailIdentities = await findMemberIdentitiesByValue(qx, memberId, identity.value, { + type: MemberIdentityType.EMAIL, + }) + + secondaryIdentities = uniqBy([...allEmailIdentities, identity], (i) => i.id) + } + + // Ensure primary member retains at least one identity + const primaryIdentities = member.identities.filter( + (i: IMemberIdentity) => + !secondaryIdentities.some( + (s) => s.platform === i.platform && s.value === i.value && s.type === i.type, + ), + ) + + if (primaryIdentities.length === 0) { + throw new BadRequestError('Cannot unmerge: primary member must retain at least one identity') + } + + const secondaryDisplayName = getProperDisplayName(identity.value) + const secondaryAttributes: IAttributes = {} + + const botDetection = new BotDetectionService(logger).isMemberBot( + secondaryIdentities, + secondaryAttributes, + secondaryDisplayName, + ) + + if (botDetection === MemberBotDetection.CONFIRMED_BOT) { + secondaryAttributes.isBot = { default: true, system: true } + } + + return { + primary: { + ...pick(member, MEMBER_MERGE_FIELDS), + identities: primaryIdentities, + memberOrganizations, + organizations: calculateRenderFriendlyOrganizations(memberOrganizations), + username: getUsernameFromIdentities(primaryIdentities), + numberOfOpenSourceContributions: member.contributions?.length ?? 0, + }, + secondary: { + id: randomUUID(), + reach: { total: -1 }, + username: getUsernameFromIdentities(secondaryIdentities), + displayName: secondaryDisplayName, + identities: secondaryIdentities, + memberOrganizations: [], + organizations: [], + attributes: secondaryAttributes, + joinedAt: new Date().toISOString(), + affiliations: [], + contributions: [], + manuallyCreated: true, + manuallyChangedFields: [], + numberOfOpenSourceContributions: 0, + }, + } +} + +function sameRole(a: IMemberRoleWithOrganization, b: IMemberRoleWithOrganization) { + return a.organizationId === b.organizationId && a.title === b.title && a.dateStart === b.dateStart +} + +function toPreviewMember( + row: MemberRow, + identities: IMemberIdentity[], + affiliations: IMemberAffiliationMergeBackup[], + memberOrganizations: IMemberRoleWithOrganization[], + contributions: IMemberContribution[] | undefined, +): IMemberUnmergePreviewResult { + return { + ...row, + identities, + affiliations, + memberOrganizations, + username: getUsernameFromIdentities(identities), + organizations: calculateRenderFriendlyOrganizations(memberOrganizations), + numberOfOpenSourceContributions: contributions?.length ?? 0, + } +} + +/** + * Execute an unmerge transactionally. + * + * Removes identities from primary, creates the secondary member, + * moves affiliations and org roles, updates primary fields. + * Records the action and prevents re-merge suggestions. + */ +export async function unmergeMember( + tx: QueryExecutor, + memberId: string, + payload: IUnmergePreviewResult, + actorId?: string, +): Promise { + const member = await findMemberById(tx, memberId, [MemberField.ID]) + + if (!member) { + throw new BadRequestError(`Member ${memberId} not found`) + } + + const { primary, secondary } = payload + + // Remove identities in secondary member from primary member + // Keep unverified identities on primary only if primary also has a matching unverified identity + const identitiesToRemove = secondary.identities.filter( + (i) => + i.verified === undefined || // backwards compatibility for old identity backups + i.verified === true || + (i.verified === false && + !primary.identities.some( + (pi) => + pi.verified === false && + pi.platform === i.platform && + pi.value === i.value && + pi.type === i.type, + )), + ) + + if (identitiesToRemove.length > 0) { + await deleteManyMemberIdentities(tx, { + memberId, + identities: identitiesToRemove.map((i) => ({ + platform: i.platform, + value: i.value, + type: i.type, + })), + }) + } + + // Exclude identities in secondary that already exist on another member + const identitiesToExclude = await findAlreadyExistingVerifiedIdentities(tx, { + identities: secondary.identities.filter((i) => i.verified), + }) + + const secondaryIdentities = secondary.identities.filter( + (i) => + !identitiesToExclude.some( + (ie) => + ie.platform === i.platform && ie.value === i.value && ie.type === i.type && ie.verified, + ), + ) + + // Track organizations that don't exist (for filtering secondary orgs) + let nonExistingOrgIds: string[] = [] + // Track roles deleted from primary (for filtering primary orgs) + let rolesToDelete: IMemberRoleWithOrganization[] = [] + + // Create the secondary member + const secondaryRow = await createMember(tx, { + displayName: secondary.displayName, + joinedAt: secondary.joinedAt, + attributes: secondary.attributes, + reach: secondary.reach, + manuallyCreated: secondary.manuallyCreated, + contributions: secondary.contributions, + }) + + const secondaryId = secondaryRow.id + + // Track merge action + await addMergeAction( + tx, + MergeActionType.MEMBER, + memberId, + secondaryId, + MergeActionStep.UNMERGE_STARTED, + MergeActionState.IN_PROGRESS, + undefined, + actorId, + ) + + // Create identities for the secondary member + for (const i of secondaryIdentities) { + await createMemberIdentity(tx, { + memberId: secondaryId, + platform: i.platform, + type: i.type, + value: i.value, + sourceId: i.sourceId || null, + integrationId: i.integrationId || null, + verified: i.verified, + source: i.source, + }) + } + + // Move affiliations + if (secondary.affiliations.length > 0) { + await moveSelectedAffiliationsBetweenMembers( + tx, + memberId, + secondaryId, + secondary.affiliations.map((a) => a.id), + ) + } + + // Move memberOrganization roles + if (secondary.memberOrganizations.length > 0) { + nonExistingOrgIds = await findNonExistingOrganizationIds( + tx, + secondary.memberOrganizations.map((o) => o.organizationId), + ) + + for (const role of secondary.memberOrganizations.filter( + (r) => !nonExistingOrgIds.includes(r.organizationId), + )) { + await addMemberRole(tx, { ...role, memberId: secondaryId }) + } + + // Delete stale roles from primary that aren't in the preview + const memberOrganizationsMap = await fetchManyMemberOrgsWithOrgData(tx, [memberId]) + const memberOrganizations = memberOrganizationsMap.get(memberId) ?? [] + + rolesToDelete = memberOrganizations.filter( + (r) => + r.source !== 'ui' && + !primary.memberOrganizations.some( + (pr) => + pr.organizationId === r.organizationId && + pr.title === r.title && + pr.dateStart === r.dateStart && + pr.dateEnd === r.dateEnd, + ), + ) + + for (const role of rolesToDelete) { + await removeMemberRole(tx, role) + } + } + + // Update primary member scalar fields — only include fields present in the payload + // to avoid overwriting NOT NULL columns with null when the frontend omits them + const primaryMember = await updateMember( + tx, + memberId, + Object.fromEntries( + Object.entries({ + joinedAt: primary.joinedAt, + attributes: primary.attributes, + displayName: primary.displayName, + reach: primary.reach, + contributions: primary.contributions, + manuallyChangedFields: primary.manuallyChangedFields, + manuallyCreated: primary.manuallyCreated, + }).filter(([, v]) => v !== undefined), + ) as MemberUpdateInput, + ) + + if (!primaryMember) { + throw new BadRequestError(`Failed to update member ${memberId}`) + } + + // Add primary and secondary to no merge so they don't get suggested again + await addMemberNoMerge(tx, memberId, secondaryId) + + await setMergeAction(tx, MergeActionType.MEMBER, memberId, secondaryId, { + step: MergeActionStep.UNMERGE_SYNC_DONE, + }) + + return { + primary: toPreviewMember( + primaryMember, + payload.primary.identities, + payload.primary.affiliations, + payload.primary.memberOrganizations.filter((r) => !rolesToDelete.some((d) => sameRole(d, r))), + payload.primary.contributions, + ), + secondary: toPreviewMember( + secondaryRow, + secondaryIdentities, + payload.secondary.affiliations, + payload.secondary.memberOrganizations.filter( + (r) => !nonExistingOrgIds.includes(r.organizationId), + ), + payload.secondary.contributions, + ), + movedIdentities: secondaryIdentities, + } +} + +/** + * Trigger async post-unmerge work via Temporal. + */ +export async function startMemberUnmergeWorkflow( + temporal: TemporalClient, + args: { + primaryId: string + secondaryId: string + movedIdentities: IMemberIdentity[] + primaryDisplayName: string + secondaryDisplayName: string + actorId?: string + }, +): Promise { + await temporal.workflow.start('finishMemberUnmerging', { + taskQueue: 'entity-merging', + workflowId: `finishMemberUnmerging/${args.primaryId}/${args.secondaryId}`, + retry: { maximumAttempts: 10 }, + args: [ + args.primaryId, + args.secondaryId, + args.movedIdentities, + args.primaryDisplayName, + args.secondaryDisplayName, + args.actorId, + ], + searchAttributes: { + TenantId: [DEFAULT_TENANT_ID], + }, + }) +} diff --git a/services/libs/common_services/src/services/memberOrganization.ts b/services/libs/common_services/src/services/memberOrganization.ts new file mode 100644 index 0000000000..b20139e3e4 --- /dev/null +++ b/services/libs/common_services/src/services/memberOrganization.ts @@ -0,0 +1,113 @@ +import { IMemberOrganization, MemberRoleUnmergeStrategy } from '@crowd/types' + +function roleKey( + role: IMemberOrganization, + strategy: MemberRoleUnmergeStrategy, +): string | undefined { + if (strategy === MemberRoleUnmergeStrategy.SAME_MEMBER) { + return role.organizationId + } + return role.memberId +} + +function roleExistsInArray( + role: IMemberOrganization, + roles: IMemberOrganization[], + strategy: MemberRoleUnmergeStrategy, +): boolean { + const key = roleKey(role, strategy) + return roles.some( + (r) => + roleKey(r, strategy) === key && + r.title === role.title && + r.dateStart === role.dateStart && + r.dateEnd === role.dateEnd, + ) +} + +export function rolesIntersect( + roleA: IMemberOrganization, + roleB: IMemberOrganization, + strategy: MemberRoleUnmergeStrategy, +): boolean { + if (roleKey(roleA, strategy) !== roleKey(roleB, strategy) || roleA.title !== roleB.title) { + return false + } + + const startA = new Date(roleA.dateStart).getTime() + const endA = new Date(roleA.dateEnd).getTime() + const startB = new Date(roleB.dateStart).getTime() + const endB = new Date(roleB.dateEnd).getTime() + + return ( + (startA < startB && endA > startB) || + (startB < startA && endB > startA) || + (startA < startB && endA > endB) || + (startB < startA && endB > endA) + ) +} + +export function unmergeRoles( + mergedRoles: IMemberOrganization[], + primaryBackupRoles: IMemberOrganization[], + secondaryBackupRoles: IMemberOrganization[], + strategy: MemberRoleUnmergeStrategy, +): IMemberOrganization[] { + const unmergedRoles: IMemberOrganization[] = mergedRoles.filter( + (role) => + role.source === 'ui' || + !secondaryBackupRoles.some((r) => roleKey(r, strategy) === roleKey(role, strategy)), + ) + + const editableRoles = mergedRoles.filter( + (role) => + role.source !== 'ui' && + secondaryBackupRoles.some((r) => roleKey(r, strategy) === roleKey(role, strategy)), + ) + + for (const secondaryBackupRole of secondaryBackupRoles) { + const { dateStart, dateEnd } = secondaryBackupRole + + if (dateStart === null && dateEnd === null) { + if ( + roleExistsInArray(secondaryBackupRole, editableRoles, strategy) && + roleExistsInArray(secondaryBackupRole, primaryBackupRoles, strategy) + ) { + unmergedRoles.push(secondaryBackupRole) + } + } else if (dateStart !== null && dateEnd === null) { + const currentRoleFromPrimaryBackup = primaryBackupRoles.find( + (r) => + roleKey(r, strategy) === roleKey(secondaryBackupRole, strategy) && + r.title === secondaryBackupRole.title && + r.dateStart !== null && + r.dateEnd === null, + ) + if (currentRoleFromPrimaryBackup) { + unmergedRoles.push(currentRoleFromPrimaryBackup) + } + } else if (dateStart !== null && dateEnd !== null) { + if ( + roleExistsInArray(secondaryBackupRole, editableRoles, strategy) && + roleExistsInArray(secondaryBackupRole, primaryBackupRoles, strategy) + ) { + unmergedRoles.push(secondaryBackupRole) + } else { + const intersecting = editableRoles.find((r) => + rolesIntersect(secondaryBackupRole, r, strategy), + ) + + if (intersecting) { + const fromBackup = primaryBackupRoles.find((r) => + rolesIntersect(secondaryBackupRole, r, strategy), + ) + if (fromBackup) { + unmergedRoles.push(fromBackup) + } + } + } + } + } + + return unmergedRoles +} diff --git a/services/libs/data-access-layer/src/auditLogs/index.ts b/services/libs/data-access-layer/src/auditLogs/index.ts index 24d8566dd5..c6d690bed3 100644 --- a/services/libs/data-access-layer/src/auditLogs/index.ts +++ b/services/libs/data-access-layer/src/auditLogs/index.ts @@ -48,6 +48,8 @@ export enum ActionType { ORGANIZATIONS_CREATE = 'organizations-create', INTEGRATIONS_CONNECT = 'integrations-connect', INTEGRATIONS_RECONNECT = 'integrations-reconnect', + MEMBERS_VERIFY_IDENTITY = 'members-verify-identity', + MEMBERS_VERIFY_WORK_EXPERIENCE = 'members-verify-work-experience', } const ACTION_TYPES_ENTITY_TYPES = { @@ -65,6 +67,8 @@ const ACTION_TYPES_ENTITY_TYPES = { [ActionType.ORGANIZATIONS_CREATE]: EntityType.ORGANIZATION, [ActionType.INTEGRATIONS_CONNECT]: EntityType.INTEGRATION, [ActionType.INTEGRATIONS_RECONNECT]: EntityType.INTEGRATION, + [ActionType.MEMBERS_VERIFY_IDENTITY]: EntityType.MEMBER, + [ActionType.MEMBERS_VERIFY_WORK_EXPERIENCE]: EntityType.MEMBER, } let qx: QueryExecutor | undefined = undefined diff --git a/services/libs/data-access-layer/src/index.ts b/services/libs/data-access-layer/src/index.ts index 0a576d8064..dafdef7f4b 100644 --- a/services/libs/data-access-layer/src/index.ts +++ b/services/libs/data-access-layer/src/index.ts @@ -3,6 +3,7 @@ export * from './activityRelations' export * from './dashboards' export * from './members' export * from './organizations' +export * from './member-organization-affiliation' export * from './prompt-history' export * from './queryExecutor' export * from './repositories' @@ -10,3 +11,4 @@ export * from './security_insights' export * from './systemSettings' export * from './integrations' export * from './auditLogs' +export * from './maintainers' diff --git a/services/libs/data-access-layer/src/member-organization-affiliation/index.ts b/services/libs/data-access-layer/src/member-organization-affiliation/index.ts index bca0a7550f..b8501bfc62 100644 --- a/services/libs/data-access-layer/src/member-organization-affiliation/index.ts +++ b/services/libs/data-access-layer/src/member-organization-affiliation/index.ts @@ -1,7 +1,13 @@ import _ from 'lodash' +import { v4 as uuid } from 'uuid' import { getLongestDateRange } from '@crowd/common' import { getServiceChildLogger } from '@crowd/logging' +import { + IChangeAffiliationOverrideData, + IMemberOrganization, + IMemberOrganizationAffiliationOverride, +} from '@crowd/types' import { findMemberAffiliations } from '../member_segment_affiliations' import { IManualAffiliationData } from '../old/apps/data_sink_worker/repo/memberAffiliation.data' @@ -367,3 +373,241 @@ export async function refreshMemberOrganizationAffiliations(qx: QueryExecutor, m logger.info({ memberId }, `Refreshed ${processed} activities in ${duration}ms`) } + +export async function changeMemberOrganizationAffiliationOverrides( + qx: QueryExecutor, + data: IChangeAffiliationOverrideData[], +): Promise { + if (!Array.isArray(data) || data.length === 0) { + return + } + + const rows: IMemberOrganizationAffiliationOverride[] = [] + + for (const d of data) { + if ( + !d.memberId || + !d.memberOrganizationId || + (d.allowAffiliation === undefined && d.isPrimaryWorkExperience === undefined) + ) { + continue + } + + rows.push({ + id: uuid(), + memberId: d.memberId, + memberOrganizationId: d.memberOrganizationId, + allowAffiliation: d.allowAffiliation, + isPrimaryWorkExperience: d.isPrimaryWorkExperience, + }) + } + + if (rows.length === 0) { + return + } + + const valuesSql = rows + .map( + (_, i) => ` + ( + $(id_${i}), + $(memberId_${i}), + $(memberOrganizationId_${i}), + $(allowAffiliation_${i}), + $(isPrimaryWorkExperience_${i}) + ) + `, + ) + .join(', ') + + const params = rows.reduce( + (acc, row, i) => { + acc[`id_${i}`] = row.id + acc[`memberId_${i}`] = row.memberId + acc[`memberOrganizationId_${i}`] = row.memberOrganizationId + acc[`allowAffiliation_${i}`] = row.allowAffiliation + acc[`isPrimaryWorkExperience_${i}`] = row.isPrimaryWorkExperience + return acc + }, + {} as Record, + ) + + await qx.result( + ` + INSERT INTO "memberOrganizationAffiliationOverrides" ( + id, + "memberId", + "memberOrganizationId", + "allowAffiliation", + "isPrimaryWorkExperience" + ) + VALUES ${valuesSql} + ON CONFLICT ("memberId", "memberOrganizationId") + DO UPDATE SET + "allowAffiliation" = COALESCE(EXCLUDED."allowAffiliation", "memberOrganizationAffiliationOverrides"."allowAffiliation"), + "isPrimaryWorkExperience" = COALESCE(EXCLUDED."isPrimaryWorkExperience", "memberOrganizationAffiliationOverrides"."isPrimaryWorkExperience"); + `, + params, + ) +} + +export async function findMemberAffiliationOverrides( + qx: QueryExecutor, + memberId: string, + memberOrganizationIds?: string[], +): Promise { + const whereClause = ['"memberId" = $(memberId)'] + + if (memberOrganizationIds?.length) { + whereClause.push(`"memberOrganizationId" IN ($(memberOrganizationIds:csv))`) + } + + const overrides: IMemberOrganizationAffiliationOverride[] = await qx.select( + ` + SELECT + id, + "memberId", + "memberOrganizationId", + coalesce("allowAffiliation", true) as "allowAffiliation", + coalesce("isPrimaryWorkExperience", false) as "isPrimaryWorkExperience" + FROM "memberOrganizationAffiliationOverrides" + WHERE ${whereClause.join(' AND ')} + `, + { + memberId, + memberOrganizationIds, + }, + ) + + if (!memberOrganizationIds?.length) { + return overrides + } + + // Map over requested memberOrganizationIds and provide defaults for missing ones + const foundMemberOrgIds = new Set(overrides.map((override) => override.memberOrganizationId)) + + const results = memberOrganizationIds.map((memberOrganizationId) => { + if (foundMemberOrgIds.has(memberOrganizationId)) { + return overrides.find((override) => override.memberOrganizationId === memberOrganizationId) + } + return { + allowAffiliation: true, + isPrimaryWorkExperience: false, + memberId, + memberOrganizationId, + } + }) + + return results +} + +export async function findOrganizationAffiliationOverrides( + qx: QueryExecutor, + organizationId: string, +): Promise { + return qx.select( + ` + SELECT + moa.id, + moa."memberId", + moa."memberOrganizationId", + coalesce(moa."allowAffiliation", true) as "allowAffiliation", + coalesce(moa."isPrimaryWorkExperience", false) as "isPrimaryWorkExperience" + FROM "memberOrganizationAffiliationOverrides" moa + JOIN "memberOrganizations" mo ON moa."memberOrganizationId" = mo.id + WHERE mo."organizationId" = $(organizationId) + AND mo."deletedAt" IS NULL + `, + { + organizationId, + }, + ) +} + +export async function findPrimaryWorkExperiencesOfMember( + qx: QueryExecutor, + memberId: string, +): Promise { + const overrides: IMemberOrganizationAffiliationOverride[] = await qx.select( + ` + SELECT + id, + "memberId", + "memberOrganizationId", + coalesce("allowAffiliation", true) as "allowAffiliation", + coalesce("isPrimaryWorkExperience", false) as "isPrimaryWorkExperience" + FROM "memberOrganizationAffiliationOverrides" + WHERE "memberId" = $(memberId) + AND "isPrimaryWorkExperience" = true + `, + { + memberId, + }, + ) + + return overrides +} + +export async function fetchOrganizationMembersWithoutAffiliationOverride( + qx: QueryExecutor, + organizationId: string, + allowAffiliation: boolean, + afterId?: string, + limit = 100, +): Promise { + return qx.select( + ` + SELECT mo.id, mo."memberId" + FROM "memberOrganizations" mo + WHERE mo."organizationId" = $(organizationId) + AND mo."deletedAt" IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM "memberOrganizationAffiliationOverrides" moao + WHERE moao."memberOrganizationId" = mo.id + AND moao."allowAffiliation" = $(allowAffiliation) + ) + ${afterId ? `AND mo.id > $(afterId)` : ''} + ORDER BY mo.id + LIMIT $(limit) + `, + { + organizationId, + allowAffiliation, + limit, + afterId, + }, + ) +} + +export async function applyOrganizationAffiliationPolicyToMembers( + qx: QueryExecutor, + organizationId: string, + allowAffiliation: boolean, +) { + let afterId + + do { + // We fetch members whose current override doesn't match the desired org-level affiliation policy. + // This avoids rewriting rows that already comply. + const memberOrgs = await fetchOrganizationMembersWithoutAffiliationOverride( + qx, + organizationId, + allowAffiliation, + afterId, + ) + + if (memberOrgs.length === 0) break + + await changeMemberOrganizationAffiliationOverrides( + qx, + memberOrgs.map((mo) => ({ + memberId: mo.memberId, + memberOrganizationId: mo.id, + allowAffiliation, + })), + ) + + afterId = memberOrgs[memberOrgs.length - 1].id + } while (afterId) +} diff --git a/services/libs/data-access-layer/src/member_organization_affiliation_overrides/index.ts b/services/libs/data-access-layer/src/member_organization_affiliation_overrides/index.ts deleted file mode 100644 index 924c39f337..0000000000 --- a/services/libs/data-access-layer/src/member_organization_affiliation_overrides/index.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { v4 as uuid } from 'uuid' - -import { - IChangeAffiliationOverrideData, - IMemberOrganization, - IMemberOrganizationAffiliationOverride, -} from '@crowd/types' - -import { QueryExecutor } from '../queryExecutor' - -export async function changeMemberOrganizationAffiliationOverrides( - qx: QueryExecutor, - data: IChangeAffiliationOverrideData[], -): Promise { - if (!Array.isArray(data) || data.length === 0) { - return - } - - const rows: IMemberOrganizationAffiliationOverride[] = [] - - for (const d of data) { - if ( - !d.memberId || - !d.memberOrganizationId || - (d.allowAffiliation === undefined && d.isPrimaryWorkExperience === undefined) - ) { - continue - } - - rows.push({ - id: uuid(), - memberId: d.memberId, - memberOrganizationId: d.memberOrganizationId, - allowAffiliation: d.allowAffiliation, - isPrimaryWorkExperience: d.isPrimaryWorkExperience, - }) - } - - if (rows.length === 0) { - return - } - - const valuesSql = rows - .map( - (_, i) => ` - ( - $(id_${i}), - $(memberId_${i}), - $(memberOrganizationId_${i}), - $(allowAffiliation_${i}), - $(isPrimaryWorkExperience_${i}) - ) - `, - ) - .join(', ') - - const params = rows.reduce( - (acc, row, i) => { - acc[`id_${i}`] = row.id - acc[`memberId_${i}`] = row.memberId - acc[`memberOrganizationId_${i}`] = row.memberOrganizationId - acc[`allowAffiliation_${i}`] = row.allowAffiliation - acc[`isPrimaryWorkExperience_${i}`] = row.isPrimaryWorkExperience - return acc - }, - {} as Record, - ) - - await qx.result( - ` - INSERT INTO "memberOrganizationAffiliationOverrides" ( - id, - "memberId", - "memberOrganizationId", - "allowAffiliation", - "isPrimaryWorkExperience" - ) - VALUES ${valuesSql} - ON CONFLICT ("memberId", "memberOrganizationId") - DO UPDATE SET - "allowAffiliation" = COALESCE(EXCLUDED."allowAffiliation", "memberOrganizationAffiliationOverrides"."allowAffiliation"), - "isPrimaryWorkExperience" = COALESCE(EXCLUDED."isPrimaryWorkExperience", "memberOrganizationAffiliationOverrides"."isPrimaryWorkExperience"); - `, - params, - ) -} - -export async function findMemberAffiliationOverrides( - qx: QueryExecutor, - memberId: string, - memberOrganizationIds?: string[], -): Promise { - const whereClause = ['"memberId" = $(memberId)'] - - if (memberOrganizationIds?.length) { - whereClause.push(`"memberOrganizationId" IN ($(memberOrganizationIds:csv))`) - } - - const overrides: IMemberOrganizationAffiliationOverride[] = await qx.select( - ` - SELECT - id, - "memberId", - "memberOrganizationId", - coalesce("allowAffiliation", true) as "allowAffiliation", - coalesce("isPrimaryWorkExperience", false) as "isPrimaryWorkExperience" - FROM "memberOrganizationAffiliationOverrides" - WHERE ${whereClause.join(' AND ')} - `, - { - memberId, - memberOrganizationIds, - }, - ) - - if (!memberOrganizationIds?.length) { - return overrides - } - - // Map over requested memberOrganizationIds and provide defaults for missing ones - const foundMemberOrgIds = new Set(overrides.map((override) => override.memberOrganizationId)) - - const results = memberOrganizationIds.map((memberOrganizationId) => { - if (foundMemberOrgIds.has(memberOrganizationId)) { - return overrides.find((override) => override.memberOrganizationId === memberOrganizationId) - } - return { - allowAffiliation: true, - isPrimaryWorkExperience: false, - memberId, - memberOrganizationId, - } - }) - - return results -} - -export async function findOrganizationAffiliationOverrides( - qx: QueryExecutor, - organizationId: string, -): Promise { - return qx.select( - ` - SELECT - moa.id, - moa."memberId", - moa."memberOrganizationId", - coalesce(moa."allowAffiliation", true) as "allowAffiliation", - coalesce(moa."isPrimaryWorkExperience", false) as "isPrimaryWorkExperience" - FROM "memberOrganizationAffiliationOverrides" moa - JOIN "memberOrganizations" mo ON moa."memberOrganizationId" = mo.id - WHERE mo."organizationId" = $(organizationId) - AND mo."deletedAt" IS NULL - `, - { - organizationId, - }, - ) -} - -export async function findPrimaryWorkExperiencesOfMember( - qx: QueryExecutor, - memberId: string, -): Promise { - const overrides: IMemberOrganizationAffiliationOverride[] = await qx.select( - ` - SELECT - id, - "memberId", - "memberOrganizationId", - coalesce("allowAffiliation", true) as "allowAffiliation", - coalesce("isPrimaryWorkExperience", false) as "isPrimaryWorkExperience" - FROM "memberOrganizationAffiliationOverrides" - WHERE "memberId" = $(memberId) - AND "isPrimaryWorkExperience" = true - `, - { - memberId, - }, - ) - - return overrides -} - -export async function fetchOrganizationMembersWithoutAffiliationOverride( - qx: QueryExecutor, - organizationId: string, - allowAffiliation: boolean, - afterId?: string, - limit = 100, -): Promise { - return qx.select( - ` - SELECT mo.id, mo."memberId" - FROM "memberOrganizations" mo - WHERE mo."organizationId" = $(organizationId) - AND mo."deletedAt" IS NULL - AND NOT EXISTS ( - SELECT 1 - FROM "memberOrganizationAffiliationOverrides" moao - WHERE moao."memberOrganizationId" = mo.id - AND moao."allowAffiliation" = $(allowAffiliation) - ) - ${afterId ? `AND mo.id > $(afterId)` : ''} - ORDER BY mo.id - LIMIT $(limit) - `, - { - organizationId, - allowAffiliation, - limit, - afterId, - }, - ) -} - -export async function applyOrganizationAffiliationPolicyToMembers( - qx: QueryExecutor, - organizationId: string, - allowAffiliation: boolean, -) { - let afterId - - do { - // We fetch members whose current override doesn't match the desired org-level affiliation policy. - // This avoids rewriting rows that already comply. - const memberOrgs = await fetchOrganizationMembersWithoutAffiliationOverride( - qx, - organizationId, - allowAffiliation, - afterId, - ) - - if (memberOrgs.length === 0) break - - await changeMemberOrganizationAffiliationOverrides( - qx, - memberOrgs.map((mo) => ({ - memberId: mo.memberId, - memberOrganizationId: mo.id, - allowAffiliation, - })), - ) - - afterId = memberOrgs[memberOrgs.length - 1].id - } while (afterId) -} diff --git a/services/libs/data-access-layer/src/member_segment_affiliations/index.ts b/services/libs/data-access-layer/src/member_segment_affiliations/index.ts index d5369efb6b..b684bb6610 100644 --- a/services/libs/data-access-layer/src/member_segment_affiliations/index.ts +++ b/services/libs/data-access-layer/src/member_segment_affiliations/index.ts @@ -68,3 +68,22 @@ export async function fetchMemberAffiliations( }, ) } + +export async function moveSelectedAffiliationsBetweenMembers( + qx: QueryExecutor, + fromMemberId: string, + toMemberId: string, + affiliationIds: string[], +): Promise { + if (affiliationIds.length === 0) return + + await qx.result( + ` + UPDATE "memberSegmentAffiliations" + SET "memberId" = $(toMemberId) + WHERE "memberId" = $(fromMemberId) + AND "id" IN ($(affiliationIds:csv)) + `, + { fromMemberId, toMemberId, affiliationIds }, + ) +} diff --git a/services/libs/data-access-layer/src/members/base.ts b/services/libs/data-access-layer/src/members/base.ts index 4f493389a0..1715fe5238 100644 --- a/services/libs/data-access-layer/src/members/base.ts +++ b/services/libs/data-access-layer/src/members/base.ts @@ -11,13 +11,16 @@ import { import { formatSql, getDbInstance, prepareForModification } from '@crowd/database' import { getServiceLogger } from '@crowd/logging' import { RedisClient } from '@crowd/redis' -import { ALL_PLATFORM_TYPES, MemberAttributeType, PageData, SegmentType } from '@crowd/types' +import { + ALL_PLATFORM_TYPES, + IMemberContribution, + MemberAttributeType, + MemberRow, + PageData, + SegmentType, +} from '@crowd/types' import { findMaintainerRoles } from '../maintainers' -import { - IDbMemberCreateData, - IDbMemberUpdateData, -} from '../old/apps/data_sink_worker/repo/member.data' import { QueryExecutor } from '../queryExecutor' import { fetchManySegments } from '../segments' import { QueryOptions, QueryResult, queryTable, queryTableById } from '../utils' @@ -72,6 +75,25 @@ export enum MemberField { UPDATED_BY_ID = 'updatedById', } +export interface MemberCreateInput { + displayName: string + joinedAt: string + attributes: Record + reach: Partial> + manuallyCreated?: boolean + contributions?: IMemberContribution[] +} + +export interface MemberUpdateInput { + joinedAt?: string + attributes?: Record + displayName?: string + reach?: Partial> + contributions?: IMemberContribution[] | string + manuallyChangedFields?: string[] + manuallyCreated?: boolean +} + export const MEMBER_MERGE_FIELDS = [ 'affiliations', 'attributes', @@ -82,9 +104,6 @@ export const MEMBER_MERGE_FIELDS = [ 'manuallyChangedFields', 'manuallyCreated', 'reach', - 'tags', - 'tasks', - 'tenantId', ] export const MEMBER_UPDATE_COLUMNS = [ @@ -108,10 +127,12 @@ export const MEMBER_SELECT_COLUMNS = [ export const MEMBER_INSERT_COLUMNS = [ 'attributes', + 'contributions', 'createdAt', 'displayName', 'id', 'joinedAt', + 'manuallyCreated', 'reach', 'tenantId', 'updatedAt', @@ -582,8 +603,8 @@ export async function moveAffiliationsBetweenMembers( export async function updateMember( qx: QueryExecutor, id: string, - data: IDbMemberUpdateData, -): Promise { + data: MemberUpdateInput, +): Promise { // Only allow updating columns that actually exist in the `members` table. // This prevents runtime SQL errors when higher-level code passes extra fields // (e.g. `affiliations`, `tags`, `tasks`, etc.) that are not actually columns. @@ -603,7 +624,7 @@ export async function updateMember( const keys = Object.keys(dbData) if (keys.length === 0) { - return + return undefined } if (typeof dbData.displayName === 'string' && dbData.displayName) { @@ -641,10 +662,10 @@ export async function updateMember( updatedAt, }) - await qx.result(`${query} ${condition}`) + return qx.selectOneOrNone(`${query} ${condition} returning *`) } -export async function createMember(qx: QueryExecutor, data: IDbMemberCreateData): Promise { +export async function createMember(qx: QueryExecutor, data: MemberCreateInput): Promise { const id = generateUUIDv1() const ts = new Date() const dbInstance = getDbInstance() @@ -653,18 +674,21 @@ export async function createMember(qx: QueryExecutor, data: IDbMemberCreateData) table: 'members', }, }) - const prepared = prepareForModification( - { - ...data, - id, - tenantId: DEFAULT_TENANT_ID, - createdAt: ts, - updatedAt: ts, - }, - columnSet, - ) + + const dbData: Record = { + ...data, + id, + tenantId: DEFAULT_TENANT_ID, + createdAt: ts, + updatedAt: ts, + } + + if (Array.isArray(dbData.contributions)) { + dbData.contributions = JSON.stringify(dbData.contributions) + } + + const prepared = prepareForModification(dbData, columnSet) const query = dbInstance.helpers.insert(prepared, columnSet) - await qx.select(query) - return id + return qx.selectOne(`${query} returning *`) } diff --git a/services/libs/data-access-layer/src/members/identities.ts b/services/libs/data-access-layer/src/members/identities.ts index fc7496d81d..7cb77e1ee5 100644 --- a/services/libs/data-access-layer/src/members/identities.ts +++ b/services/libs/data-access-layer/src/members/identities.ts @@ -1,5 +1,10 @@ import { DEFAULT_TENANT_ID } from '@crowd/common' -import { IMemberIdentity, MemberIdentityType, NewMemberIdentity } from '@crowd/types' +import { + IMemberIdentity, + MemberIdentityType, + NewMemberIdentity, + UpdateMemberIdentity, +} from '@crowd/types' import { MEMBER_SELECT_COLUMNS } from '../members/base' import { IDbMember } from '../old/apps/data_sink_worker/repo/member.data' @@ -12,7 +17,7 @@ export async function fetchMemberIdentities( ): Promise { return qx.select( ` - SELECT id, platform, "sourceId", source, type, value, verified + SELECT * FROM "memberIdentities" WHERE "memberId" = $(memberId) AND "deletedAt" is null @@ -70,7 +75,7 @@ export async function findMemberIdentityById( ): Promise { const res = await qx.select( ` - SELECT id, platform, "sourceId", source, type, value, verified + SELECT * FROM "memberIdentities" WHERE "id" = $(id) AND "memberId" = $(memberId) @@ -81,6 +86,7 @@ export async function findMemberIdentityById( memberId, }, ) + return res.length > 0 ? res[0] : null } @@ -107,33 +113,25 @@ export async function updateMemberIdentity( qx: QueryExecutor, memberId: string, id: string, - data: Partial, -): Promise { - return qx.result( - ` - UPDATE "memberIdentities" - SET - platform = $(platform), - type = $(type), - value = $(value), - verified = $(verified), - "sourceId" = $(sourceId), - "integrationId" = $(integrationId) - WHERE "memberId" = $(memberId) - AND "id" = $(id) - AND "deletedAt" is null; - `, - { - memberId, - id, - platform: data.platform, - type: data.type, - value: data.value, - verified: data.verified || false, - sourceId: data.sourceId || null, - integrationId: data.integrationId || null, - }, - ) + data: Partial, +): Promise { + if (Object.keys(data).length === 0) return null + + const setClause = Object.keys(data).map((key) => `"${key}" = $(${key})`) + setClause.push('"updatedAt" = now()') + + const params = { memberId, id, ...data } + + const query = ` + UPDATE "memberIdentities" + SET ${setClause.join(', ')} + WHERE "memberId" = $(memberId) + AND "id" = $(id) + AND "deletedAt" IS NULL + RETURNING *; + ` + + return qx.selectOneOrNone(query, params) } export async function deleteMemberIdentity( @@ -570,3 +568,53 @@ export async function findIdentitiesForMembers( return resultMap } + +/** + * Retrieve member IDs matching any of the given identities. + */ +export async function findMemberIdsByIdentities( + qx: QueryExecutor, + identities: Partial[], +): Promise { + if (!identities.length) return [] + + const conditions: string[] = [] + const params: Record = {} + + identities.forEach((identity, i) => { + const parts: string[] = [] + + Object.entries(identity).forEach(([key, value]) => { + if (value == null) return + + const paramName = `${key}_${i}` + + // Special handling: lowercase 'value' for case-insensitive match + if (key === 'value') { + parts.push(`lower(mi.${key}) = $(${paramName})`) + params[paramName] = (value as string).toLowerCase() + } else { + parts.push(`mi.${key} = $(${paramName})`) + params[paramName] = value as string + } + }) + + if (parts.length > 0) { + conditions.push(`(${parts.join(' AND ')})`) + } + }) + + if (!conditions.length) return [] + + const result = await qx.select( + ` + SELECT DISTINCT mi."memberId" + FROM "memberIdentities" mi + WHERE mi."deletedAt" IS NULL + AND (${conditions.join(' OR ')}) + `, + params, + ) + + return result.map((r) => r.memberId) +} diff --git a/services/libs/data-access-layer/src/members/organizations.ts b/services/libs/data-access-layer/src/members/organizations.ts index 85e9179d08..636bb87068 100644 --- a/services/libs/data-access-layer/src/members/organizations.ts +++ b/services/libs/data-access-layer/src/members/organizations.ts @@ -2,6 +2,7 @@ import { IMemberOrganization, IMemberOrganizationAffiliationOverride, IMemberRoleWithOrganization, + MemberOrganizationUpdate, OrganizationSource, } from '@crowd/types' @@ -9,7 +10,7 @@ import { changeMemberOrganizationAffiliationOverrides, findMemberAffiliationOverrides, findOrganizationAffiliationOverrides, -} from '../member_organization_affiliation_overrides' +} from '../member-organization-affiliation' import { EntityType } from '../old/apps/script_executor_worker/types' import { QueryExecutor } from '../queryExecutor' @@ -142,23 +143,46 @@ export async function createMemberOrganization( ): Promise { const result = await qx.selectOneOrNone( ` - INSERT INTO "memberOrganizations"("memberId", "organizationId", "dateStart", "dateEnd", "title", "source", "createdAt", "updatedAt") - VALUES($(memberId), $(organizationId), $(dateStart), $(dateEnd), $(title), $(source), now(), now()) - on conflict do nothing returning id; + INSERT INTO "memberOrganizations"( + "memberId", + "organizationId", + "dateStart", + "dateEnd", + "title", + "source", + "verified", + "verifiedBy", + "createdAt", + "updatedAt" + ) + VALUES( + $(memberId), + $(organizationId), + $(dateStart), + $(dateEnd), + $(title), + $(source), + $(verified), + $(verifiedBy), + now(), + now() + ) + ON CONFLICT DO NOTHING + RETURNING id `, { memberId, organizationId: data.organizationId, - dateStart: data.dateStart, - dateEnd: data.dateEnd, - title: data.title || null, - source: data.source || null, + dateStart: data.dateStart ?? null, + dateEnd: data.dateEnd ?? null, + title: data.title ?? null, + source: data.source ?? null, + verified: data.verified ?? false, + verifiedBy: data.verifiedBy ?? null, }, ) - if (result) { - return result.id - } + return result?.id } export async function createOrUpdateMemberOrganizations( @@ -268,31 +292,23 @@ export async function updateMemberOrganization( qx: QueryExecutor, memberId: string, id: string, - data: Partial, -): Promise { - await qx.result( - ` - UPDATE "memberOrganizations" - SET - "organizationId" = $(organizationId), - "dateStart" = $(dateStart), - "dateEnd" = $(dateEnd), - title = $(title), - source = $(source), - "updatedAt" = $(updatedAt) - WHERE "memberId" = $(memberId) AND "id" = $(id); - `, - { - memberId, - id, - organizationId: data.organizationId, - dateStart: data.dateStart, - dateEnd: data.dateEnd, - title: data.title, - source: data.source, - updatedAt: new Date().toISOString(), - }, - ) + data: MemberOrganizationUpdate, +): Promise { + const setClause = Object.keys(data).map((key) => `"${key}" = $(${key})`) + setClause.push('"updatedAt" = now()') + + const params = { memberId, id, ...data } + + const query = ` + UPDATE "memberOrganizations" + SET ${setClause.join(', ')} + WHERE "id" = $(id) + AND "memberId" = $(memberId) + AND "deletedAt" IS NULL + RETURNING *; + ` + + return qx.selectOneOrNone(query, params) } export async function deleteMemberOrganizations( diff --git a/services/libs/data-access-layer/src/mergeActions/repo.ts b/services/libs/data-access-layer/src/mergeActions/repo.ts index 53f0d95b02..629ca69c26 100644 --- a/services/libs/data-access-layer/src/mergeActions/repo.ts +++ b/services/libs/data-access-layer/src/mergeActions/repo.ts @@ -2,11 +2,13 @@ import validator from 'validator' import { DEFAULT_TENANT_ID } from '@crowd/common' import { + IMemberIdentity, IMemberUnmergeBackup, IMergeAction, IMergeActionColumns, IOrganizationUnmergeBackup, IUnmergeBackup, + MemberIdentityType, MergeActionState, MergeActionStep, MergeActionType, @@ -150,3 +152,49 @@ export async function addMergeAction( }, ) } + +export async function findMergeBackup( + qx: QueryExecutor, + primaryId: string, + type: MergeActionType.MEMBER, + identity: IMemberIdentity, +): Promise { + const records: IMergeAction[] = await qx.select( + ` + SELECT * + FROM "mergeActions" ma + WHERE ma."primaryId" = $(primaryId) + AND EXISTS( + SELECT 1 + FROM jsonb_array_elements(ma."unmergeBackup" -> 'secondary' -> 'identities') AS identities + WHERE (identities ->> 'username' = $(value) + OR (identities ->> 'type' = $(type) AND identities ->> 'value' = $(value))) + AND identities ->> 'platform' = $(platform) + ) + `, + { + primaryId, + value: identity.value, + type: identity.type, + platform: identity.platform, + }, + ) + + for (const record of records) { + if (record.type === MergeActionType.MEMBER && record.unmergeBackup) { + const backup = record.unmergeBackup as IUnmergeBackup + for (const side of [backup.primary, backup.secondary]) { + if (!side) continue + for (const id of side.identities) { + if ('username' in id) { + id.value = (id as Record).username as string + id.type = MemberIdentityType.USERNAME + delete (id as Record).username + } + } + } + } + } + + return records.length > 0 ? records[0] : null +} diff --git a/services/libs/data-access-layer/src/organizations/base.ts b/services/libs/data-access-layer/src/organizations/base.ts index 799406fa63..3a64c5cdf8 100644 --- a/services/libs/data-access-layer/src/organizations/base.ts +++ b/services/libs/data-access-layer/src/organizations/base.ts @@ -683,3 +683,30 @@ export async function findOrgById( ): Promise> { return queryTableById(qx, 'organizations', Object.values(OrganizationField), orgId, fields) } + +export async function findNonExistingOrganizationIds( + qx: QueryExecutor, + ids: string[], +): Promise { + if (ids.length === 0) return [] + + const valuesClause = ids.map((_, i) => `($(id_${i})::uuid)`).join(', ') + const params: Record = {} + ids.forEach((id, i) => { + params[`id_${i}`] = id + }) + + const rows = await qx.select( + ` + WITH id_list (id) AS (VALUES ${valuesClause}) + SELECT id_list.id + FROM id_list + WHERE NOT EXISTS ( + SELECT 1 FROM organizations o WHERE o.id = id_list.id + ) + `, + params, + ) + + return rows.map((r: { id: string }) => r.id) +} diff --git a/services/libs/types/src/members.ts b/services/libs/types/src/members.ts index df2fbe5a68..fc31cacdf4 100644 --- a/services/libs/types/src/members.ts +++ b/services/libs/types/src/members.ts @@ -24,6 +24,7 @@ export interface IMemberIdentity { type: MemberIdentityType memberId?: string verified: boolean + verifiedBy?: string source?: string sourceId?: string @@ -34,11 +35,17 @@ export interface IMemberIdentity { deletedAt?: string } +export type MemberIdentityField = keyof IMemberIdentity + export type NewMemberIdentity = Omit< IMemberIdentity, 'id' | 'createdAt' | 'updatedAt' | 'deletedAt' > +export type UpdateMemberIdentity = Partial< + Omit +> + export interface IActivityIdentity { username: string platform: string diff --git a/services/libs/types/src/merging.ts b/services/libs/types/src/merging.ts index 9eb79a2423..10f4de3524 100644 --- a/services/libs/types/src/merging.ts +++ b/services/libs/types/src/merging.ts @@ -60,7 +60,6 @@ export interface IMemberUnmergePreviewResult { id: string reach: IMemberReach joinedAt: string - tenantId: string username: IMemberUsername attributes: IAttributes displayName: string @@ -85,6 +84,27 @@ export interface IOrganizationUnmergePreviewResult extends IOrganization { activityCount: number } +export interface MemberRow { + id: string + displayName: string + joinedAt: string + attributes: Record + reach: Record + contributions: IMemberContribution[] | null + manuallyCreated: boolean + manuallyChangedFields: string[] | null + score: number | null + tenantId: string + createdAt: string + updatedAt: string +} + +export interface MemberUnmergeResult { + primary: IMemberUnmergePreviewResult + secondary: IMemberUnmergePreviewResult + movedIdentities: IMemberIdentity[] +} + export interface ILLMSuggestionVerdict { id?: string type: LLMSuggestionVerdictType diff --git a/services/libs/types/src/organizations.ts b/services/libs/types/src/organizations.ts index e0d36f8819..d5b01d6beb 100644 --- a/services/libs/types/src/organizations.ts +++ b/services/libs/types/src/organizations.ts @@ -59,11 +59,27 @@ export interface IMemberOrganization { updatedAt?: string createdAt?: string source?: string + verified?: boolean + verifiedBy?: string deletedAt?: string displayName?: string affiliationOverride?: IMemberOrganizationAffiliationOverride } +type MemberOrganizationEditableFields = Pick< + IMemberOrganization, + | 'organizationId' + | 'memberId' + | 'title' + | 'dateStart' + | 'dateEnd' + | 'source' + | 'verified' + | 'verifiedBy' +> + +export type MemberOrganizationUpdate = Partial + export interface IRenderFriendlyMemberOrganization { id: string displayName?: string