From a5907a01826c4b4b1fdc171b746ae798581f8b50 Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:02:30 +0530 Subject: [PATCH 01/11] fix: precompute project group merge suggestion counts Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com> --- ...28525__segment-merge-suggestion-counts.sql | 6 + .../database/repositories/memberRepository.ts | 86 ++---- .../repositories/organizationRepository.ts | 147 ++------- backend/src/services/memberService.ts | 14 +- backend/src/services/organizationService.ts | 21 +- ...refreshSegmentMergeSuggestionCounts.job.ts | 81 +++++ .../src/services/common.member.service.ts | 11 + .../data-access-layer/src/segments/index.ts | 285 +++++++++++++++++- 8 files changed, 474 insertions(+), 177 deletions(-) create mode 100644 backend/src/database/migrations/V1782128525__segment-merge-suggestion-counts.sql create mode 100644 services/apps/cron_service/src/jobs/refreshSegmentMergeSuggestionCounts.job.ts diff --git a/backend/src/database/migrations/V1782128525__segment-merge-suggestion-counts.sql b/backend/src/database/migrations/V1782128525__segment-merge-suggestion-counts.sql new file mode 100644 index 0000000000..6f3b9b0ff8 --- /dev/null +++ b/backend/src/database/migrations/V1782128525__segment-merge-suggestion-counts.sql @@ -0,0 +1,6 @@ +create table "segmentMergeSuggestionCounts" ( + "segmentId" uuid primary key references "segments" ("id") on delete cascade, + "memberMergeSuggestionsCount" integer not null default 0, + "organizationMergeSuggestionsCount" integer not null default 0, + "updatedAt" timestamp with time zone +); \ No newline at end of file diff --git a/backend/src/database/repositories/memberRepository.ts b/backend/src/database/repositories/memberRepository.ts index 5a6f668d81..dcc0611887 100644 --- a/backend/src/database/repositories/memberRepository.ts +++ b/backend/src/database/repositories/memberRepository.ts @@ -51,7 +51,11 @@ import { } from '@crowd/data-access-layer/src/members/segments' import { IDbMemberData } from '@crowd/data-access-layer/src/members/types' import { optionsQx } from '@crowd/data-access-layer/src/queryExecutor' -import { fetchManySegments, getSegmentSubprojectIds } from '@crowd/data-access-layer/src/segments' +import { + fetchManySegments, + getSegmentMergeSuggestionCounts, + getSegmentSubprojectIds, +} from '@crowd/data-access-layer/src/segments' import { ActivityDisplayService } from '@crowd/integrations' import { ALL_PLATFORM_TYPES, @@ -63,6 +67,8 @@ import { MemberIdentityType, MemberSegmentAffiliation, MemberSegmentAffiliationJoined, + MergeActionState, + MergeActionType, PlatformType, SegmentType, TemporalWorkflowId, @@ -249,46 +255,19 @@ class MemberRepository { } static async countMemberMergeSuggestions( - memberFilter: string, - similarityFilter: string, - displayNameFilter: string, - replacements: { - segmentIds: string[] - memberId?: string - displayName?: string - }, + segmentIds: string[], options: IRepositoryOptions, ): Promise { - const membersJoin = displayNameFilter - ? `JOIN members m ON m.id = mtm."memberId" - JOIN members m2 ON m2.id = mtm."toMergeId"` - : '' + if (segmentIds.length !== 1) { + return 0 + } - const totalCount = await options.database.sequelize.query( - ` - SELECT - COUNT(*) AS count - FROM "memberToMerge" mtm - ${membersJoin} - WHERE EXISTS ( - SELECT 1 FROM "memberSegmentsAgg" ms - WHERE ms."memberId" = mtm."memberId" AND ms."segmentId" IN (:segmentIds) - ) - AND EXISTS ( - SELECT 1 FROM "memberSegmentsAgg" ms2 - WHERE ms2."memberId" = mtm."toMergeId" AND ms2."segmentId" IN (:segmentIds) - ) - ${memberFilter} - ${similarityFilter} - ${displayNameFilter} - `, - { - replacements, - type: QueryTypes.SELECT, - }, + const counts = await getSegmentMergeSuggestionCounts( + SequelizeRepository.getQueryExecutor(options), + segmentIds[0], ) - return totalCount[0]?.count || 0 + return counts?.memberMergeSuggestionsCount ?? 0 } static async findMembersWithMergeSuggestions( @@ -358,17 +337,7 @@ class MemberRepository { } if (args.countOnly) { - const totalCount = await this.countMemberMergeSuggestions( - memberFilter, - similarityFilter, - displayNameFilter, - { - segmentIds, - displayName: args?.filter?.displayName ? `${args.filter.displayName}%` : undefined, - memberId: args?.filter?.memberId, - }, - options, - ) + const totalCount = await this.countMemberMergeSuggestions(segmentIds, options) return { count: totalCount } } @@ -387,6 +356,12 @@ class MemberRepository { FROM "memberToMerge" mtm JOIN members m ON m.id = mtm."memberId" JOIN members m2 ON m2.id = mtm."toMergeId" + LEFT JOIN "mergeActions" ma + ON ma.type = :mergeActionType + AND ( + (ma."primaryId" = mtm."memberId" AND ma."secondaryId" = mtm."toMergeId") + OR (ma."primaryId" = mtm."toMergeId" AND ma."secondaryId" = mtm."memberId") + ) WHERE EXISTS ( SELECT 1 FROM "memberSegmentsAgg" ms WHERE ms."memberId" = mtm."memberId" AND ms."segmentId" IN (:segmentIds) @@ -395,6 +370,7 @@ class MemberRepository { SELECT 1 FROM "memberSegmentsAgg" ms2 WHERE ms2."memberId" = mtm."toMergeId" AND ms2."segmentId" IN (:segmentIds) ) + AND (ma.id IS NULL OR ma.state = :mergeActionState) AND mtm.similarity IS NOT NULL ${memberFilter} ${similarityFilter} @@ -410,6 +386,8 @@ class MemberRepository { offset: args.offset, displayName: args?.filter?.displayName ? `${args.filter.displayName}%` : undefined, memberId: args?.filter?.memberId, + mergeActionType: MergeActionType.MEMBER, + mergeActionState: MergeActionState.ERROR, }, type: QueryTypes.SELECT, }, @@ -506,24 +484,14 @@ class MemberRepository { })) } - const totalCount = await this.countMemberMergeSuggestions( - memberFilter, - similarityFilter, - displayNameFilter, - { - segmentIds, - memberId: args?.filter?.memberId, - displayName: args?.filter?.displayName ? `${args.filter.displayName}%` : undefined, - }, - options, - ) + const totalCount = await this.countMemberMergeSuggestions(segmentIds, options) return { rows: result, count: totalCount, limit: args.limit, offset: args.offset } } return { rows: [{ members: [], similarity: 0 }], - count: 0, + count: await this.countMemberMergeSuggestions(segmentIds, options), limit: args.limit, offset: args.offset, } diff --git a/backend/src/database/repositories/organizationRepository.ts b/backend/src/database/repositories/organizationRepository.ts index e64d83eb8d..27938cc909 100644 --- a/backend/src/database/repositories/organizationRepository.ts +++ b/backend/src/database/repositories/organizationRepository.ts @@ -34,7 +34,11 @@ import { } from '@crowd/data-access-layer/src/organizations' import { findAttribute } from '@crowd/data-access-layer/src/organizations/attributesConfig' import { optionsQx } from '@crowd/data-access-layer/src/queryExecutor' -import { findSegmentById, getSegmentSubprojectIds } from '@crowd/data-access-layer/src/segments' +import { + findSegmentById, + getSegmentMergeSuggestionCounts, + getSegmentSubprojectIds, +} from '@crowd/data-access-layer/src/segments' import { IMemberRenderFriendlyRole, IMemberRoleWithOrganization, @@ -789,57 +793,19 @@ class OrganizationRepository { } static async countOrganizationMergeSuggestions( - organizationFilter: string, - similarityFilter: string, - displayNameFilter: string, - replacements: { - segmentIds: string[] - organizationId?: string - displayName?: string - mergeActionType: MergeActionType - mergeActionStatus: MergeActionState - }, + segmentIds: string[], options: IRepositoryOptions, ): Promise { - const organizationsJoin = displayNameFilter - ? `JOIN organizations o1 ON o1.id = otm."organizationId" - JOIN organizations o2 ON o2.id = otm."toMergeId"` - : '' + if (segmentIds.length !== 1) { + return 0 + } - const result = await options.database.sequelize.query( - ` - SELECT COUNT(DISTINCT Greatest( - Hashtext(Concat(otm."organizationId", otm."toMergeId")), - Hashtext(Concat(otm."toMergeId", otm."organizationId")) - )) AS total_count - FROM "organizationToMerge" otm - ${organizationsJoin} - LEFT JOIN "mergeActions" ma - ON ma.type = :mergeActionType - AND ( - (ma."primaryId" = otm."organizationId" AND ma."secondaryId" = otm."toMergeId") - OR (ma."primaryId" = otm."toMergeId" AND ma."secondaryId" = otm."organizationId") - ) - WHERE EXISTS ( - SELECT 1 FROM "organizationSegmentsAgg" os1 - WHERE os1."organizationId" = otm."organizationId" AND os1."segmentId" IN (:segmentIds) - ) - AND EXISTS ( - SELECT 1 FROM "organizationSegmentsAgg" os2 - WHERE os2."organizationId" = otm."toMergeId" AND os2."segmentId" IN (:segmentIds) - ) - AND (ma.id IS NULL OR ma.state = :mergeActionStatus) - ${organizationFilter} - ${similarityFilter} - ${displayNameFilter} - `, - { - replacements, - type: QueryTypes.SELECT, - }, + const counts = await getSegmentMergeSuggestionCounts( + SequelizeRepository.getQueryExecutor(options), + segmentIds[0], ) - return result[0]?.total_count || 0 + return counts?.organizationMergeSuggestionsCount ?? 0 } static async findOrganizationsWithMergeSuggestions( @@ -880,48 +846,32 @@ class OrganizationRepository { ? ` and (o1."displayName" ilike :displayName OR o2."displayName" ilike :displayName)` : '' - let order = - '"organizationsToMerge".similarity desc, "organizationsToMerge"."id", "organizationsToMerge"."toMergeId"' + let order = 'otm.similarity desc, otm."organizationId", otm."toMergeId"' if (args.orderBy?.length > 0) { order = '' for (const orderBy of args.orderBy) { const [field, direction] = orderBy.split('_') if (['similarity'].includes(field) && ['asc', 'desc'].includes(direction.toLowerCase())) { - order += `"organizationsToMerge".${field} ${direction}, ` + order += `otm.${field} ${direction}, ` } } - order += '"organizationsToMerge"."id", "organizationsToMerge"."toMergeId"' + order += 'otm."organizationId", otm."toMergeId"' } if (args.countOnly) { - const totalCount = await this.countOrganizationMergeSuggestions( - organizationFilter, - similarityFilter, - displayNameFilter, - { - segmentIds, - displayName: args?.filter?.displayName ? `${args.filter.displayName}%` : undefined, - organizationId: args?.filter?.organizationId, - mergeActionType: MergeActionType.ORG, - mergeActionStatus: MergeActionState.ERROR, - }, - options, - ) + const totalCount = await this.countOrganizationMergeSuggestions(segmentIds, options) return { count: totalCount } } const orgs = await options.database.sequelize.query( - `WITH - cte AS ( + ` SELECT - Greatest(Hashtext(Concat(otm."organizationId", otm."toMergeId")), Hashtext(Concat(otm."toMergeId", otm."organizationId"))) as hash, - otm."organizationId" as id, + otm."organizationId" AS id, otm."toMergeId", - o1."createdAt", - otm."similarity", + otm.similarity, o1."displayName" as "primaryDisplayName", o1.logo as "primaryLogo", o2."displayName" as "secondaryDisplayName", @@ -949,51 +899,13 @@ class OrganizationRepository { SELECT 1 FROM "organizationSegmentsAgg" os2 WHERE os2."organizationId" = otm."toMergeId" AND os2."segmentId" IN (:segmentIds) ) - AND (ma.id IS NULL OR ma.state = :mergeActionStatus) + AND (ma.id IS NULL OR ma.state = :mergeActionState) ${organizationFilter} ${similarityFilter} ${displayNameFilter} - ), - - count_cte AS ( - SELECT COUNT(DISTINCT hash) AS total_count - FROM cte - ), - - final_select AS ( - SELECT DISTINCT ON (hash) - id, - "toMergeId", - "primaryDisplayName", - "primaryLogo", - "secondaryDisplayName", - "secondaryLogo", - "createdAt", - "similarity", - "primarySegmentId", - "secondarySegmentId" - FROM cte - ORDER BY hash, id - ) - - SELECT - "organizationsToMerge".id, - "organizationsToMerge"."toMergeId", - "organizationsToMerge"."primaryDisplayName", - "organizationsToMerge"."primaryLogo", - "organizationsToMerge"."secondaryDisplayName", - "organizationsToMerge"."secondaryLogo", - "organizationsToMerge"."primarySegmentId", - "organizationsToMerge"."secondarySegmentId", - count_cte."total_count", - "organizationsToMerge"."similarity" - FROM - final_select AS "organizationsToMerge", - count_cte - ORDER BY - ${order} - LIMIT :limit OFFSET :offset - `, + ORDER BY ${order} + LIMIT :limit OFFSET :offset + `, { replacements: { segmentIds, @@ -1001,7 +913,7 @@ class OrganizationRepository { offset: args.offset, displayName: args?.filter?.displayName ? `${args.filter.displayName}%` : undefined, mergeActionType: MergeActionType.ORG, - mergeActionStatus: MergeActionState.ERROR, + mergeActionState: MergeActionState.ERROR, organizationId: args?.filter?.organizationId, }, type: QueryTypes.SELECT, @@ -1060,12 +972,17 @@ class OrganizationRepository { }) }) - return { rows: result, count: orgs[0].total_count, limit: args.limit, offset: args.offset } + return { + rows: result, + count: await this.countOrganizationMergeSuggestions(segmentIds, options), + limit: args.limit, + offset: args.offset, + } } return { rows: [{ organizations: [], similarity: 0 }], - count: 0, + count: await this.countOrganizationMergeSuggestions(segmentIds, options), limit: args.limit, offset: args.offset, } diff --git a/backend/src/services/memberService.ts b/backend/src/services/memberService.ts index 1189c5dce4..f4e82fac67 100644 --- a/backend/src/services/memberService.ts +++ b/backend/src/services/memberService.ts @@ -22,7 +22,11 @@ import { queryMembersAdvanced, } from '@crowd/data-access-layer/src/members' import { QueryExecutor, optionsQx } from '@crowd/data-access-layer/src/queryExecutor' -import { fetchManySegments } from '@crowd/data-access-layer/src/segments' +import { + decrementMemberMergeSuggestionCounts, + fetchManySegments, + getMembersCommonProjectGroupSegmentIds, +} from '@crowd/data-access-layer/src/segments' import { LoggerBase } from '@crowd/logging' import { IMemberIdentity, @@ -778,6 +782,14 @@ export default class MemberService extends LoggerBase { await SequelizeRepository.commitTransaction(transaction) + const qx = SequelizeRepository.getQueryExecutor(this.options) + const projectGroupSegmentIds = await getMembersCommonProjectGroupSegmentIds(qx, [ + memberOneId, + memberTwoId, + ]) + + await decrementMemberMergeSuggestionCounts(qx, projectGroupSegmentIds) + return { status: 200 } } catch (error) { await SequelizeRepository.rollbackTransaction(transaction) diff --git a/backend/src/services/organizationService.ts b/backend/src/services/organizationService.ts index 1cc0fa5adc..d495c71eb9 100644 --- a/backend/src/services/organizationService.ts +++ b/backend/src/services/organizationService.ts @@ -29,7 +29,11 @@ import { findOrgById, upsertOrgIdentities, } from '@crowd/data-access-layer/src/organizations' -import { findLfSegmentByName } from '@crowd/data-access-layer/src/segments' +import { + decrementOrganizationMergeSuggestionCounts, + findLfSegmentByName, + getOrganizationsCommonProjectGroupSegmentIds, +} from '@crowd/data-access-layer/src/segments' import { LoggerBase } from '@crowd/logging' import { WorkflowIdReusePolicy } from '@crowd/temporal' import { @@ -722,6 +726,13 @@ export default class OrganizationService extends LoggerBase { await SequelizeRepository.commitTransaction(tx) + const projectGroupSegmentIds = await getOrganizationsCommonProjectGroupSegmentIds(qx, [ + originalId, + toMergeId, + ]) + + await decrementOrganizationMergeSuggestionCounts(qx, projectGroupSegmentIds) + this.log.info({ originalId, toMergeId }, '[Merge Organizations] - Transaction commited!') await setMergeAction( @@ -832,6 +843,14 @@ export default class OrganizationService extends LoggerBase { }) await SequelizeRepository.commitTransaction(transaction) + + const qx = SequelizeRepository.getQueryExecutor(this.options) + const projectGroupSegmentIds = await getOrganizationsCommonProjectGroupSegmentIds(qx, [ + organizationId, + noMergeId, + ]) + + await decrementOrganizationMergeSuggestionCounts(qx, projectGroupSegmentIds) } catch (error) { await SequelizeRepository.rollbackTransaction(transaction) diff --git a/services/apps/cron_service/src/jobs/refreshSegmentMergeSuggestionCounts.job.ts b/services/apps/cron_service/src/jobs/refreshSegmentMergeSuggestionCounts.job.ts new file mode 100644 index 0000000000..328157227e --- /dev/null +++ b/services/apps/cron_service/src/jobs/refreshSegmentMergeSuggestionCounts.job.ts @@ -0,0 +1,81 @@ +import CronTime from 'cron-time-generator' + +import { + READ_DB_CONFIG, + WRITE_DB_CONFIG, + getDbConnection, +} from '@crowd/data-access-layer/src/database' +import { chunkArray } from '@crowd/data-access-layer/src/old/apps/merge_suggestions_worker/utils' +import { pgpQx } from '@crowd/data-access-layer/src/queryExecutor' +import { + calculateSegmentMemberMergeSuggestionsCount, + calculateSegmentOrganizationMergeSuggestionsCount, + fetchProjectGroupSegmentIds, + getSegmentMergeSuggestionCounts, + upsertSegmentMergeSuggestionCounts, +} from '@crowd/data-access-layer/src/segments' + +import { IJobDefinition } from '../types' + +const job: IJobDefinition = { + name: 'refresh-segment-merge-suggestion-counts', + cronTime: CronTime.every(4).hours(), + timeout: 2 * 60 * 60, + process: async (ctx) => { + const readDb = await getDbConnection(READ_DB_CONFIG(), 3, 0) + const writeDb = await getDbConnection(WRITE_DB_CONFIG(), 1, 0) + + const readQx = pgpQx(readDb) + const writeQx = pgpQx(writeDb) + + const segmentIds = await fetchProjectGroupSegmentIds(readQx) + const BATCH_SIZE = 5 + + const failedSegmentIds: string[] = [] + + ctx.log.info({ segmentCount: segmentIds.length }, 'Refreshing segment merge suggestion counts') + + for (const batch of chunkArray(segmentIds, BATCH_SIZE)) { + await Promise.all( + batch.map(async (segmentId) => { + try { + const [memberMergeSuggestionsCount, organizationMergeSuggestionsCount] = + await Promise.all([ + calculateSegmentMemberMergeSuggestionsCount(readQx, segmentId), + calculateSegmentOrganizationMergeSuggestionsCount(readQx, segmentId), + ]) + + const smsc = await getSegmentMergeSuggestionCounts(readQx, segmentId) + + if ( + smsc?.memberMergeSuggestionsCount !== memberMergeSuggestionsCount || + smsc?.organizationMergeSuggestionsCount !== organizationMergeSuggestionsCount + ) { + await upsertSegmentMergeSuggestionCounts(writeQx, segmentId, { + memberMergeSuggestionsCount, + organizationMergeSuggestionsCount, + }) + } + + ctx.log.debug({ segmentId }, 'Refreshed segment merge suggestion counts') + } catch (err) { + failedSegmentIds.push(segmentId) + ctx.log.error(err, { segmentId }, 'Segment merge suggestion count refresh failed') + } + }), + ) + } + + const failedCount = failedSegmentIds.length + + if (failedCount > 0) { + throw new Error( + `Segment merge suggestion counts refresh failed for ${failedCount}/${segmentIds.length} segments (${failedSegmentIds.join(', ')})`, + ) + } + + ctx.log.info('Segment merge suggestion counts refresh finished') + }, +} + +export default job 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 ee4a6da890..33b033e899 100644 --- a/services/libs/common_services/src/services/common.member.service.ts +++ b/services/libs/common_services/src/services/common.member.service.ts @@ -56,6 +56,10 @@ import { } from '@crowd/data-access-layer/src/mergeActions/repo' import { IWorkExperienceData } from '@crowd/data-access-layer/src/old/apps/data_sink_worker/repo/memberAffiliation.data' import { addOrgsToSegments } from '@crowd/data-access-layer/src/organizations' +import { + decrementMemberMergeSuggestionCounts, + getMembersCommonProjectGroupSegmentIds, +} from '@crowd/data-access-layer/src/segments' import { Logger, LoggerBase } from '@crowd/logging' import { Client as TemporalClient } from '@crowd/temporal' import { MergeActionState, MergeActionStep, MergeActionType } from '@crowd/types' @@ -440,6 +444,13 @@ export class CommonMemberService extends LoggerBase { ) }) + const projectGroupSegmentIds = await getMembersCommonProjectGroupSegmentIds(this.qx, [ + originalId, + toMergeId, + ]) + + await decrementMemberMergeSuggestionCounts(this.qx, projectGroupSegmentIds) + this.log.info({ originalId, toMergeId }, '[Merge Members] - Transaction commited! ') await setMergeAction(this.qx, MergeActionType.MEMBER, originalId, toMergeId, { diff --git a/services/libs/data-access-layer/src/segments/index.ts b/services/libs/data-access-layer/src/segments/index.ts index a3cef8a2e5..17daeb4734 100644 --- a/services/libs/data-access-layer/src/segments/index.ts +++ b/services/libs/data-access-layer/src/segments/index.ts @@ -3,7 +3,14 @@ import cloneDeep from 'lodash.clonedeep' import { DEFAULT_TENANT_ID } from '@crowd/common' import { DEFAULT_ACTIVITY_TYPE_SETTINGS } from '@crowd/integrations' -import { ActivityTypeSettings, PlatformType, SegmentData, SegmentRawData } from '@crowd/types' +import { + ActivityTypeSettings, + MergeActionState, + MergeActionType, + PlatformType, + SegmentData, + SegmentRawData, +} from '@crowd/types' import { QueryExecutor } from '../queryExecutor' @@ -558,3 +565,279 @@ export async function getSubProjectsCount( projectsLast30Days: parseInt(result.projectsLast30Days) || 0, } } + +export async function fetchProjectGroupSegmentIds(qx: QueryExecutor): Promise { + const rows: { id: string }[] = await qx.select( + ` + SELECT id + FROM segments + WHERE "parentId" IS NULL + AND "grandparentId" IS NULL + AND "tenantId" = $(tenantId) + AND status = 'active' + ORDER BY id + `, + { + tenantId: DEFAULT_TENANT_ID, + }, + ) + + return rows.map((row) => row.id) +} + +export async function calculateSegmentMemberMergeSuggestionsCount( + qx: QueryExecutor, + segmentId: string, +): Promise { + const result = await qx.selectOne( + ` + SELECT COUNT(*) AS count + FROM "memberToMerge" mtm + LEFT JOIN "mergeActions" ma + ON ma.type = $(mergeActionType) + AND ( + (ma."primaryId" = mtm."memberId" AND ma."secondaryId" = mtm."toMergeId") + OR (ma."primaryId" = mtm."toMergeId" AND ma."secondaryId" = mtm."memberId") + ) + WHERE EXISTS ( + SELECT 1 + FROM "memberSegmentsAgg" ms + WHERE ms."memberId" = mtm."memberId" + AND ms."segmentId" = $(segmentId) + ) + AND EXISTS ( + SELECT 1 + FROM "memberSegmentsAgg" ms2 + WHERE ms2."memberId" = mtm."toMergeId" + AND ms2."segmentId" = $(segmentId) + ) + AND (ma.id IS NULL OR ma.state = $(mergeActionState)) + `, + { + segmentId, + mergeActionType: MergeActionType.MEMBER, + mergeActionState: MergeActionState.ERROR, + }, + ) + + return Number(result.count) +} + +export async function calculateSegmentOrganizationMergeSuggestionsCount( + qx: QueryExecutor, + segmentId: string, +): Promise { + const result = await qx.selectOne( + ` + SELECT COUNT(*) AS count + FROM "organizationToMerge" otm + LEFT JOIN "mergeActions" ma + ON ma.type = $(mergeActionType) + AND ( + (ma."primaryId" = otm."organizationId" AND ma."secondaryId" = otm."toMergeId") + OR (ma."primaryId" = otm."toMergeId" AND ma."secondaryId" = otm."organizationId") + ) + WHERE EXISTS ( + SELECT 1 + FROM "organizationSegmentsAgg" os1 + WHERE os1."organizationId" = otm."organizationId" + AND os1."segmentId" = $(segmentId) + ) + AND EXISTS ( + SELECT 1 + FROM "organizationSegmentsAgg" os2 + WHERE os2."organizationId" = otm."toMergeId" + AND os2."segmentId" = $(segmentId) + ) + AND (ma.id IS NULL OR ma.state = $(mergeActionState)) + `, + { + segmentId, + mergeActionType: MergeActionType.ORG, + mergeActionState: MergeActionState.ERROR, + }, + ) + + return Number(result.count) +} + +interface SegmentMergeSuggestionCounts { + memberMergeSuggestionsCount: number + organizationMergeSuggestionsCount: number +} + +export async function upsertSegmentMergeSuggestionCounts( + qx: QueryExecutor, + segmentId: string, + data: SegmentMergeSuggestionCounts, +): Promise { + const { memberMergeSuggestionsCount, organizationMergeSuggestionsCount } = data + + await qx.result( + ` + INSERT INTO "segmentMergeSuggestionCounts" ( + "segmentId", + "memberMergeSuggestionsCount", + "organizationMergeSuggestionsCount", + "updatedAt" + ) + VALUES ( + $(segmentId), + $(memberMergeSuggestionsCount), + $(organizationMergeSuggestionsCount), + now() + ) + ON CONFLICT ("segmentId") + DO UPDATE SET + "memberMergeSuggestionsCount" = EXCLUDED."memberMergeSuggestionsCount", + "organizationMergeSuggestionsCount" = EXCLUDED."organizationMergeSuggestionsCount", + "updatedAt" = EXCLUDED."updatedAt" + `, + { + segmentId, + memberMergeSuggestionsCount, + organizationMergeSuggestionsCount, + }, + ) +} + +export async function getSegmentMergeSuggestionCounts( + qx: QueryExecutor, + segmentId: string, +): Promise { + const result = await qx.selectOneOrNone( + ` + SELECT + "memberMergeSuggestionsCount", + "organizationMergeSuggestionsCount" + FROM "segmentMergeSuggestionCounts" + WHERE "segmentId" = $(segmentId) + `, + { + segmentId, + }, + ) + + if (!result) { + return null + } + + return { + memberMergeSuggestionsCount: Number(result.memberMergeSuggestionsCount), + organizationMergeSuggestionsCount: Number(result.organizationMergeSuggestionsCount), + } +} + +export async function getMembersCommonProjectGroupSegmentIds( + qx: QueryExecutor, + memberIds: string[], +): Promise { + if (!memberIds || memberIds.length !== 2) { + throw new Error('Exactly two memberIds are required') + } + + const [memberId, otherMemberId] = memberIds + + const rows: { segmentId: string }[] = await qx.select( + ` + SELECT DISTINCT ms."segmentId" + FROM "memberSegmentsAgg" ms + INNER JOIN "memberSegmentsAgg" ms2 + ON ms."segmentId" = ms2."segmentId" + INNER JOIN segments s + ON s.id = ms."segmentId" + WHERE ms."memberId" = $(memberId) + AND ms2."memberId" = $(otherMemberId) + AND s."parentId" IS NULL + AND s."grandparentId" IS NULL + AND s."tenantId" = $(tenantId) + AND s.status = 'active' + `, + { + memberId, + otherMemberId, + tenantId: DEFAULT_TENANT_ID, + }, + ) + + return rows.map((row) => row.segmentId) +} + +export async function getOrganizationsCommonProjectGroupSegmentIds( + qx: QueryExecutor, + organizationIds: string[], +): Promise { + if (!organizationIds || organizationIds.length !== 2) { + throw new Error('Exactly two organizationIds are required') + } + + const [organizationId, otherOrganizationId] = organizationIds + + const rows: { segmentId: string }[] = await qx.select( + ` + SELECT DISTINCT os1."segmentId" + FROM "organizationSegmentsAgg" os1 + INNER JOIN "organizationSegmentsAgg" os2 + ON os1."segmentId" = os2."segmentId" + INNER JOIN segments s + ON s.id = os1."segmentId" + WHERE os1."organizationId" = $(organizationId) + AND os2."organizationId" = $(otherOrganizationId) + AND s."parentId" IS NULL + AND s."grandparentId" IS NULL + AND s."tenantId" = $(tenantId) + AND s.status = 'active' + `, + { + organizationId, + otherOrganizationId, + tenantId: DEFAULT_TENANT_ID, + }, + ) + + return rows.map((row) => row.segmentId) +} + +export async function decrementMemberMergeSuggestionCounts( + qx: QueryExecutor, + segmentIds: string[], +): Promise { + if (segmentIds.length === 0) { + return + } + + await qx.result( + ` + UPDATE "segmentMergeSuggestionCounts" + SET + "memberMergeSuggestionsCount" = GREATEST(0, "memberMergeSuggestionsCount" - 1), + "updatedAt" = now() + WHERE "segmentId" IN ($(segmentIds:csv)) + `, + { + segmentIds, + }, + ) +} + +export async function decrementOrganizationMergeSuggestionCounts( + qx: QueryExecutor, + segmentIds: string[], +): Promise { + if (segmentIds.length === 0) { + return + } + + await qx.result( + ` + UPDATE "segmentMergeSuggestionCounts" + SET + "organizationMergeSuggestionsCount" = GREATEST(0, "organizationMergeSuggestionsCount" - 1), + "updatedAt" = now() + WHERE "segmentId" IN ($(segmentIds:csv)) + `, + { + segmentIds, + }, + ) +} From f06a3be33254360f9a6691d8d603d5dc0bb4ed53 Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:16:25 +0530 Subject: [PATCH 02/11] fix: return count as a number instead of a string in member repository Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com> --- backend/src/database/repositories/memberRepository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/database/repositories/memberRepository.ts b/backend/src/database/repositories/memberRepository.ts index dcc0611887..f94478c89d 100644 --- a/backend/src/database/repositories/memberRepository.ts +++ b/backend/src/database/repositories/memberRepository.ts @@ -283,7 +283,7 @@ class MemberRepository { if (segmentIds.length === 0) { return args.countOnly - ? { count: '0' } + ? { count: 0 } : { rows: [{ members: [], similarity: 0 }], count: 0, From 8958312ef6422879ad5ac046bdc10de217f23ee4 Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:42:09 +0530 Subject: [PATCH 03/11] revert: member and organization count filters Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com> --- .../database/repositories/memberRepository.ts | 91 ++++++++++++++++--- .../repositories/organizationRepository.ts | 87 +++++++++++++++--- 2 files changed, 150 insertions(+), 28 deletions(-) diff --git a/backend/src/database/repositories/memberRepository.ts b/backend/src/database/repositories/memberRepository.ts index f94478c89d..e11ee7e8f6 100644 --- a/backend/src/database/repositories/memberRepository.ts +++ b/backend/src/database/repositories/memberRepository.ts @@ -255,19 +255,57 @@ class MemberRepository { } static async countMemberMergeSuggestions( - segmentIds: string[], + memberFilter: string, + similarityFilter: string, + displayNameFilter: string, + replacements: { + segmentIds: string[] + memberId?: string + displayName?: string + }, options: IRepositoryOptions, ): Promise { - if (segmentIds.length !== 1) { - return 0 - } + const membersJoin = displayNameFilter + ? `JOIN members m ON m.id = mtm."memberId" + JOIN members m2 ON m2.id = mtm."toMergeId"` + : '' - const counts = await getSegmentMergeSuggestionCounts( - SequelizeRepository.getQueryExecutor(options), - segmentIds[0], + const totalCount = await options.database.sequelize.query( + ` + SELECT + COUNT(*) AS count + FROM "memberToMerge" mtm + ${membersJoin} + LEFT JOIN "mergeActions" ma + ON ma.type = :mergeActionType + AND ( + (ma."primaryId" = mtm."memberId" AND ma."secondaryId" = mtm."toMergeId") + OR (ma."primaryId" = mtm."toMergeId" AND ma."secondaryId" = mtm."memberId") + ) + WHERE EXISTS ( + SELECT 1 FROM "memberSegmentsAgg" ms + WHERE ms."memberId" = mtm."memberId" AND ms."segmentId" IN (:segmentIds) + ) + AND EXISTS ( + SELECT 1 FROM "memberSegmentsAgg" ms2 + WHERE ms2."memberId" = mtm."toMergeId" AND ms2."segmentId" IN (:segmentIds) + ) + AND (ma.id IS NULL OR ma.state = :mergeActionState) + ${memberFilter} + ${similarityFilter} + ${displayNameFilter} + `, + { + replacements: { + ...replacements, + mergeActionType: MergeActionType.MEMBER, + mergeActionState: MergeActionState.ERROR, + }, + type: QueryTypes.SELECT, + }, ) - return counts?.memberMergeSuggestionsCount ?? 0 + return totalCount[0]?.count || 0 } static async findMembersWithMergeSuggestions( @@ -336,10 +374,35 @@ class MemberRepository { order += 'mtm."memberId", mtm."toMergeId"' } - if (args.countOnly) { - const totalCount = await this.countMemberMergeSuggestions(segmentIds, options) + const hasCountFilters = Boolean( + args.filter?.memberId || args.filter?.displayName || args.filter?.similarity?.length, + ) + + const getTotalCount = async (): Promise => { + if (segmentIds.length === 1 && !hasCountFilters) { + const counts = await getSegmentMergeSuggestionCounts( + SequelizeRepository.getQueryExecutor(options), + segmentIds[0], + ) - return { count: totalCount } + return counts?.memberMergeSuggestionsCount ?? 0 + } + + return this.countMemberMergeSuggestions( + memberFilter, + similarityFilter, + displayNameFilter, + { + segmentIds, + memberId: args?.filter?.memberId, + displayName: args?.filter?.displayName ? `${args.filter.displayName}%` : undefined, + }, + options, + ) + } + + if (args.countOnly) { + return { count: await getTotalCount() } } const mems = await options.database.sequelize.query( @@ -484,14 +547,12 @@ class MemberRepository { })) } - const totalCount = await this.countMemberMergeSuggestions(segmentIds, options) - - return { rows: result, count: totalCount, limit: args.limit, offset: args.offset } + return { rows: result, count: await getTotalCount(), limit: args.limit, offset: args.offset } } return { rows: [{ members: [], similarity: 0 }], - count: await this.countMemberMergeSuggestions(segmentIds, options), + count: await getTotalCount(), limit: args.limit, offset: args.offset, } diff --git a/backend/src/database/repositories/organizationRepository.ts b/backend/src/database/repositories/organizationRepository.ts index 27938cc909..348218c4fe 100644 --- a/backend/src/database/repositories/organizationRepository.ts +++ b/backend/src/database/repositories/organizationRepository.ts @@ -793,19 +793,56 @@ class OrganizationRepository { } static async countOrganizationMergeSuggestions( - segmentIds: string[], + organizationFilter: string, + similarityFilter: string, + displayNameFilter: string, + replacements: { + segmentIds: string[] + organizationId?: string + displayName?: string + }, options: IRepositoryOptions, ): Promise { - if (segmentIds.length !== 1) { - return 0 - } + const organizationsJoin = displayNameFilter + ? `JOIN organizations o1 ON o1.id = otm."organizationId" + JOIN organizations o2 ON o2.id = otm."toMergeId"` + : '' - const counts = await getSegmentMergeSuggestionCounts( - SequelizeRepository.getQueryExecutor(options), - segmentIds[0], + const result = await options.database.sequelize.query( + ` + SELECT COUNT(*) AS total_count + FROM "organizationToMerge" otm + ${organizationsJoin} + LEFT JOIN "mergeActions" ma + ON ma.type = :mergeActionType + AND ( + (ma."primaryId" = otm."organizationId" AND ma."secondaryId" = otm."toMergeId") + OR (ma."primaryId" = otm."toMergeId" AND ma."secondaryId" = otm."organizationId") + ) + WHERE EXISTS ( + SELECT 1 FROM "organizationSegmentsAgg" os1 + WHERE os1."organizationId" = otm."organizationId" AND os1."segmentId" IN (:segmentIds) + ) + AND EXISTS ( + SELECT 1 FROM "organizationSegmentsAgg" os2 + WHERE os2."organizationId" = otm."toMergeId" AND os2."segmentId" IN (:segmentIds) + ) + AND (ma.id IS NULL OR ma.state = :mergeActionState) + ${organizationFilter} + ${similarityFilter} + ${displayNameFilter} + `, + { + replacements: { + ...replacements, + mergeActionType: MergeActionType.ORG, + mergeActionState: MergeActionState.ERROR, + }, + type: QueryTypes.SELECT, + }, ) - return counts?.organizationMergeSuggestionsCount ?? 0 + return result[0]?.total_count || 0 } static async findOrganizationsWithMergeSuggestions( @@ -860,10 +897,34 @@ class OrganizationRepository { order += 'otm."organizationId", otm."toMergeId"' } - if (args.countOnly) { - const totalCount = await this.countOrganizationMergeSuggestions(segmentIds, options) + const hasCountFilters = Boolean( + args.filter?.organizationId || args.filter?.displayName || args.filter?.similarity?.length, + ) + + const getTotalCount = async (): Promise => { + if (segmentIds.length === 1 && !hasCountFilters) { + const counts = await getSegmentMergeSuggestionCounts( + SequelizeRepository.getQueryExecutor(options), + segmentIds[0], + ) + return counts?.organizationMergeSuggestionsCount ?? 0 + } - return { count: totalCount } + return this.countOrganizationMergeSuggestions( + organizationFilter, + similarityFilter, + displayNameFilter, + { + segmentIds, + organizationId: args?.filter?.organizationId, + displayName: args?.filter?.displayName ? `${args.filter.displayName}%` : undefined, + }, + options, + ) + } + + if (args.countOnly) { + return { count: await getTotalCount() } } const orgs = await options.database.sequelize.query( @@ -974,7 +1035,7 @@ class OrganizationRepository { return { rows: result, - count: await this.countOrganizationMergeSuggestions(segmentIds, options), + count: await getTotalCount(), limit: args.limit, offset: args.offset, } @@ -982,7 +1043,7 @@ class OrganizationRepository { return { rows: [{ organizations: [], similarity: 0 }], - count: await this.countOrganizationMergeSuggestions(segmentIds, options), + count: await getTotalCount(), limit: args.limit, offset: args.offset, } From f20dea81b7449d7cf3903f711d09508d2ee99d76 Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:51:06 +0530 Subject: [PATCH 04/11] fix: resolve pr review comments Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com> --- .../src/services/common.member.service.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) 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 33b033e899..6b66b44804 100644 --- a/services/libs/common_services/src/services/common.member.service.ts +++ b/services/libs/common_services/src/services/common.member.service.ts @@ -444,12 +444,20 @@ export class CommonMemberService extends LoggerBase { ) }) - const projectGroupSegmentIds = await getMembersCommonProjectGroupSegmentIds(this.qx, [ - originalId, - toMergeId, - ]) - - await decrementMemberMergeSuggestionCounts(this.qx, projectGroupSegmentIds) + try { + const projectGroupSegmentIds = await getMembersCommonProjectGroupSegmentIds(this.qx, [ + originalId, + toMergeId, + ]) + + await decrementMemberMergeSuggestionCounts(this.qx, projectGroupSegmentIds) + } catch (error) { + this.log.error( + error, + { originalId, toMergeId }, + 'Failed to decrement member merge suggestion counts after merge', + ) + } this.log.info({ originalId, toMergeId }, '[Merge Members] - Transaction commited! ') From 22cc2e9174caa50b28808a887d4ced543b527b42 Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:10:04 +0530 Subject: [PATCH 05/11] fix: decrement calls and trnx usage Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com> --- backend/src/services/memberService.ts | 43 ++++++--------- backend/src/services/organizationService.ts | 55 ++++++++----------- .../src/services/common.member.service.ts | 24 +++----- 3 files changed, 50 insertions(+), 72 deletions(-) diff --git a/backend/src/services/memberService.ts b/backend/src/services/memberService.ts index f4e82fac67..f069ed6b63 100644 --- a/backend/src/services/memberService.ts +++ b/backend/src/services/memberService.ts @@ -761,41 +761,32 @@ export default class MemberService extends LoggerBase { */ async addToNoMerge(memberOneId, memberTwoId) { const transaction = await SequelizeRepository.createTransaction(this.options) + const txOptions = { ...this.options, transaction } try { - await MemberRepository.addNoMerge(memberOneId, memberTwoId, { - ...this.options, - transaction, - }) - await MemberRepository.addNoMerge(memberTwoId, memberOneId, { - ...this.options, - transaction, - }) - await MemberRepository.removeToMerge(memberOneId, memberTwoId, { - ...this.options, - transaction, - }) - await MemberRepository.removeToMerge(memberTwoId, memberOneId, { - ...this.options, - transaction, - }) + await MemberRepository.addNoMerge(memberOneId, memberTwoId, txOptions) + await MemberRepository.addNoMerge(memberTwoId, memberOneId, txOptions) + await MemberRepository.removeToMerge(memberOneId, memberTwoId, txOptions) + await MemberRepository.removeToMerge(memberTwoId, memberOneId, txOptions) await SequelizeRepository.commitTransaction(transaction) - - const qx = SequelizeRepository.getQueryExecutor(this.options) - const projectGroupSegmentIds = await getMembersCommonProjectGroupSegmentIds(qx, [ - memberOneId, - memberTwoId, - ]) - - await decrementMemberMergeSuggestionCounts(qx, projectGroupSegmentIds) - - return { status: 200 } } catch (error) { await SequelizeRepository.rollbackTransaction(transaction) throw error } + + const qx = SequelizeRepository.getQueryExecutor(this.options) + const projectGroupSegmentIds = await getMembersCommonProjectGroupSegmentIds(qx, [ + memberOneId, + memberTwoId, + ]) + + // Precomputed per-project-group counts are only refreshed by cron every few hours. + // Decrement here so no-merge from the UI is reflected immediately. + await decrementMemberMergeSuggestionCounts(qx, projectGroupSegmentIds) + + return { status: 200 } } async update( diff --git a/backend/src/services/organizationService.ts b/backend/src/services/organizationService.ts index d495c71eb9..3fe7e38cf7 100644 --- a/backend/src/services/organizationService.ts +++ b/backend/src/services/organizationService.ts @@ -726,13 +726,6 @@ export default class OrganizationService extends LoggerBase { await SequelizeRepository.commitTransaction(tx) - const projectGroupSegmentIds = await getOrganizationsCommonProjectGroupSegmentIds(qx, [ - originalId, - toMergeId, - ]) - - await decrementOrganizationMergeSuggestionCounts(qx, projectGroupSegmentIds) - this.log.info({ originalId, toMergeId }, '[Merge Organizations] - Transaction commited!') await setMergeAction( @@ -749,6 +742,15 @@ export default class OrganizationService extends LoggerBase { }), ) + const projectGroupSegmentIds = await getOrganizationsCommonProjectGroupSegmentIds(qx, [ + originalId, + toMergeId, + ]) + + // Precomputed per-project-group counts are only refreshed by cron every few hours. + // Decrement here so merges from the UI are reflected immediately. + await decrementOrganizationMergeSuggestionCounts(qx, projectGroupSegmentIds) + await this.options.temporal.workflow.start('finishOrganizationMerging', { taskQueue: 'entity-merging', workflowId: `finishOrganizationMerging/${originalId}/${toMergeId}`, @@ -823,39 +825,30 @@ export default class OrganizationService extends LoggerBase { async addToNoMerge(organizationId: string, noMergeId: string): Promise { const transaction = await SequelizeRepository.createTransaction(this.options) + const txOptions = { ...this.options, transaction } try { - await OrganizationRepository.addNoMerge(organizationId, noMergeId, { - ...this.options, - transaction, - }) - await OrganizationRepository.addNoMerge(noMergeId, organizationId, { - ...this.options, - transaction, - }) - await OrganizationRepository.removeToMerge(organizationId, noMergeId, { - ...this.options, - transaction, - }) - await OrganizationRepository.removeToMerge(noMergeId, organizationId, { - ...this.options, - transaction, - }) + await OrganizationRepository.addNoMerge(organizationId, noMergeId, txOptions) + await OrganizationRepository.addNoMerge(noMergeId, organizationId, txOptions) + await OrganizationRepository.removeToMerge(organizationId, noMergeId, txOptions) + await OrganizationRepository.removeToMerge(noMergeId, organizationId, txOptions) await SequelizeRepository.commitTransaction(transaction) - - const qx = SequelizeRepository.getQueryExecutor(this.options) - const projectGroupSegmentIds = await getOrganizationsCommonProjectGroupSegmentIds(qx, [ - organizationId, - noMergeId, - ]) - - await decrementOrganizationMergeSuggestionCounts(qx, projectGroupSegmentIds) } catch (error) { await SequelizeRepository.rollbackTransaction(transaction) throw error } + + const qx = SequelizeRepository.getQueryExecutor(this.options) + const projectGroupSegmentIds = await getOrganizationsCommonProjectGroupSegmentIds(qx, [ + organizationId, + noMergeId, + ]) + + // Precomputed per-project-group counts are only refreshed by cron every few hours. + // Decrement here so no-merge from the UI is reflected immediately. + await decrementOrganizationMergeSuggestionCounts(qx, projectGroupSegmentIds) } async createOrUpdate( 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 6b66b44804..087f079ad6 100644 --- a/services/libs/common_services/src/services/common.member.service.ts +++ b/services/libs/common_services/src/services/common.member.service.ts @@ -444,21 +444,6 @@ export class CommonMemberService extends LoggerBase { ) }) - try { - const projectGroupSegmentIds = await getMembersCommonProjectGroupSegmentIds(this.qx, [ - originalId, - toMergeId, - ]) - - await decrementMemberMergeSuggestionCounts(this.qx, projectGroupSegmentIds) - } catch (error) { - this.log.error( - error, - { originalId, toMergeId }, - 'Failed to decrement member merge suggestion counts after merge', - ) - } - this.log.info({ originalId, toMergeId }, '[Merge Members] - Transaction commited! ') await setMergeAction(this.qx, MergeActionType.MEMBER, originalId, toMergeId, { @@ -469,6 +454,15 @@ export class CommonMemberService extends LoggerBase { }), ) + const projectGroupSegmentIds = await getMembersCommonProjectGroupSegmentIds(this.qx, [ + originalId, + toMergeId, + ]) + + // Precomputed per-project-group counts are only refreshed by cron every few hours. + // Decrement here so merges from the UI are reflected immediately. + await decrementMemberMergeSuggestionCounts(this.qx, projectGroupSegmentIds) + await this.temporal.workflow.start('finishMemberMerging', { taskQueue: 'entity-merging', workflowId: `finishMemberMerging/${originalId}/${toMergeId}`, From ea0d0adabc42b3f1183281f3305d6be1f5627bc7 Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:29:10 +0530 Subject: [PATCH 06/11] fix: resolve pr review comments Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com> --- .../database/repositories/memberRepository.ts | 42 +++++++++++------- .../repositories/organizationRepository.ts | 43 ++++++++++++------- .../data-access-layer/src/segments/index.ts | 34 +++++++++------ 3 files changed, 73 insertions(+), 46 deletions(-) diff --git a/backend/src/database/repositories/memberRepository.ts b/backend/src/database/repositories/memberRepository.ts index e11ee7e8f6..abc5ea447e 100644 --- a/backend/src/database/repositories/memberRepository.ts +++ b/backend/src/database/repositories/memberRepository.ts @@ -276,12 +276,6 @@ class MemberRepository { COUNT(*) AS count FROM "memberToMerge" mtm ${membersJoin} - LEFT JOIN "mergeActions" ma - ON ma.type = :mergeActionType - AND ( - (ma."primaryId" = mtm."memberId" AND ma."secondaryId" = mtm."toMergeId") - OR (ma."primaryId" = mtm."toMergeId" AND ma."secondaryId" = mtm."memberId") - ) WHERE EXISTS ( SELECT 1 FROM "memberSegmentsAgg" ms WHERE ms."memberId" = mtm."memberId" AND ms."segmentId" IN (:segmentIds) @@ -290,7 +284,16 @@ class MemberRepository { SELECT 1 FROM "memberSegmentsAgg" ms2 WHERE ms2."memberId" = mtm."toMergeId" AND ms2."segmentId" IN (:segmentIds) ) - AND (ma.id IS NULL OR ma.state = :mergeActionState) + AND NOT EXISTS ( + SELECT 1 + FROM "mergeActions" ma + WHERE ma.type = :mergeActionType + AND ma.state <> :mergeActionState + AND ( + (ma."primaryId" = mtm."memberId" AND ma."secondaryId" = mtm."toMergeId") + OR (ma."primaryId" = mtm."toMergeId" AND ma."secondaryId" = mtm."memberId") + ) + ) ${memberFilter} ${similarityFilter} ${displayNameFilter} @@ -379,10 +382,14 @@ class MemberRepository { ) const getTotalCount = async (): Promise => { - if (segmentIds.length === 1 && !hasCountFilters) { + const projectGroupSegment = options.currentSegments?.find( + (s) => s.parentId == null && s.grandparentId == null, + ) + + if (projectGroupSegment && !hasCountFilters) { const counts = await getSegmentMergeSuggestionCounts( SequelizeRepository.getQueryExecutor(options), - segmentIds[0], + projectGroupSegment?.id, ) return counts?.memberMergeSuggestionsCount ?? 0 @@ -419,12 +426,6 @@ class MemberRepository { FROM "memberToMerge" mtm JOIN members m ON m.id = mtm."memberId" JOIN members m2 ON m2.id = mtm."toMergeId" - LEFT JOIN "mergeActions" ma - ON ma.type = :mergeActionType - AND ( - (ma."primaryId" = mtm."memberId" AND ma."secondaryId" = mtm."toMergeId") - OR (ma."primaryId" = mtm."toMergeId" AND ma."secondaryId" = mtm."memberId") - ) WHERE EXISTS ( SELECT 1 FROM "memberSegmentsAgg" ms WHERE ms."memberId" = mtm."memberId" AND ms."segmentId" IN (:segmentIds) @@ -433,7 +434,16 @@ class MemberRepository { SELECT 1 FROM "memberSegmentsAgg" ms2 WHERE ms2."memberId" = mtm."toMergeId" AND ms2."segmentId" IN (:segmentIds) ) - AND (ma.id IS NULL OR ma.state = :mergeActionState) + AND NOT EXISTS ( + SELECT 1 + FROM "mergeActions" ma + WHERE ma.type = :mergeActionType + AND ma.state <> :mergeActionState + AND ( + (ma."primaryId" = mtm."memberId" AND ma."secondaryId" = mtm."toMergeId") + OR (ma."primaryId" = mtm."toMergeId" AND ma."secondaryId" = mtm."memberId") + ) + ) AND mtm.similarity IS NOT NULL ${memberFilter} ${similarityFilter} diff --git a/backend/src/database/repositories/organizationRepository.ts b/backend/src/database/repositories/organizationRepository.ts index 348218c4fe..3570925fc6 100644 --- a/backend/src/database/repositories/organizationRepository.ts +++ b/backend/src/database/repositories/organizationRepository.ts @@ -813,12 +813,6 @@ class OrganizationRepository { SELECT COUNT(*) AS total_count FROM "organizationToMerge" otm ${organizationsJoin} - LEFT JOIN "mergeActions" ma - ON ma.type = :mergeActionType - AND ( - (ma."primaryId" = otm."organizationId" AND ma."secondaryId" = otm."toMergeId") - OR (ma."primaryId" = otm."toMergeId" AND ma."secondaryId" = otm."organizationId") - ) WHERE EXISTS ( SELECT 1 FROM "organizationSegmentsAgg" os1 WHERE os1."organizationId" = otm."organizationId" AND os1."segmentId" IN (:segmentIds) @@ -827,7 +821,16 @@ class OrganizationRepository { SELECT 1 FROM "organizationSegmentsAgg" os2 WHERE os2."organizationId" = otm."toMergeId" AND os2."segmentId" IN (:segmentIds) ) - AND (ma.id IS NULL OR ma.state = :mergeActionState) + AND NOT EXISTS ( + SELECT 1 + FROM "mergeActions" ma + WHERE ma.type = :mergeActionType + AND ma.state <> :mergeActionState + AND ( + (ma."primaryId" = otm."organizationId" AND ma."secondaryId" = otm."toMergeId") + OR (ma."primaryId" = otm."toMergeId" AND ma."secondaryId" = otm."organizationId") + ) + ) ${organizationFilter} ${similarityFilter} ${displayNameFilter} @@ -902,11 +905,16 @@ class OrganizationRepository { ) const getTotalCount = async (): Promise => { - if (segmentIds.length === 1 && !hasCountFilters) { + const projectGroupSegment = options.currentSegments?.find( + (s) => s.parentId == null && s.grandparentId == null, + ) + + if (projectGroupSegment && !hasCountFilters) { const counts = await getSegmentMergeSuggestionCounts( SequelizeRepository.getQueryExecutor(options), - segmentIds[0], + projectGroupSegment?.id, ) + return counts?.organizationMergeSuggestionsCount ?? 0 } @@ -946,12 +954,6 @@ class OrganizationRepository { FROM "organizationToMerge" otm JOIN organizations o1 ON o1.id = otm."organizationId" JOIN organizations o2 ON o2.id = otm."toMergeId" - LEFT JOIN "mergeActions" ma - ON ma.type = :mergeActionType - AND ( - (ma."primaryId" = otm."organizationId" AND ma."secondaryId" = otm."toMergeId") - OR (ma."primaryId" = otm."toMergeId" AND ma."secondaryId" = otm."organizationId") - ) WHERE EXISTS ( SELECT 1 FROM "organizationSegmentsAgg" os1 WHERE os1."organizationId" = otm."organizationId" AND os1."segmentId" IN (:segmentIds) @@ -960,7 +962,16 @@ class OrganizationRepository { SELECT 1 FROM "organizationSegmentsAgg" os2 WHERE os2."organizationId" = otm."toMergeId" AND os2."segmentId" IN (:segmentIds) ) - AND (ma.id IS NULL OR ma.state = :mergeActionState) + AND NOT EXISTS ( + SELECT 1 + FROM "mergeActions" ma + WHERE ma.type = :mergeActionType + AND ma.state <> :mergeActionState + AND ( + (ma."primaryId" = otm."organizationId" AND ma."secondaryId" = otm."toMergeId") + OR (ma."primaryId" = otm."toMergeId" AND ma."secondaryId" = otm."organizationId") + ) + ) ${organizationFilter} ${similarityFilter} ${displayNameFilter} diff --git a/services/libs/data-access-layer/src/segments/index.ts b/services/libs/data-access-layer/src/segments/index.ts index 17daeb4734..19498a39a5 100644 --- a/services/libs/data-access-layer/src/segments/index.ts +++ b/services/libs/data-access-layer/src/segments/index.ts @@ -593,12 +593,6 @@ export async function calculateSegmentMemberMergeSuggestionsCount( ` SELECT COUNT(*) AS count FROM "memberToMerge" mtm - LEFT JOIN "mergeActions" ma - ON ma.type = $(mergeActionType) - AND ( - (ma."primaryId" = mtm."memberId" AND ma."secondaryId" = mtm."toMergeId") - OR (ma."primaryId" = mtm."toMergeId" AND ma."secondaryId" = mtm."memberId") - ) WHERE EXISTS ( SELECT 1 FROM "memberSegmentsAgg" ms @@ -611,7 +605,16 @@ export async function calculateSegmentMemberMergeSuggestionsCount( WHERE ms2."memberId" = mtm."toMergeId" AND ms2."segmentId" = $(segmentId) ) - AND (ma.id IS NULL OR ma.state = $(mergeActionState)) + AND NOT EXISTS ( + SELECT 1 + FROM "mergeActions" ma + WHERE ma.type = $(mergeActionType) + AND ma.state <> $(mergeActionState) + AND ( + (ma."primaryId" = mtm."memberId" AND ma."secondaryId" = mtm."toMergeId") + OR (ma."primaryId" = mtm."toMergeId" AND ma."secondaryId" = mtm."memberId") + ) + ) `, { segmentId, @@ -631,12 +634,6 @@ export async function calculateSegmentOrganizationMergeSuggestionsCount( ` SELECT COUNT(*) AS count FROM "organizationToMerge" otm - LEFT JOIN "mergeActions" ma - ON ma.type = $(mergeActionType) - AND ( - (ma."primaryId" = otm."organizationId" AND ma."secondaryId" = otm."toMergeId") - OR (ma."primaryId" = otm."toMergeId" AND ma."secondaryId" = otm."organizationId") - ) WHERE EXISTS ( SELECT 1 FROM "organizationSegmentsAgg" os1 @@ -649,7 +646,16 @@ export async function calculateSegmentOrganizationMergeSuggestionsCount( WHERE os2."organizationId" = otm."toMergeId" AND os2."segmentId" = $(segmentId) ) - AND (ma.id IS NULL OR ma.state = $(mergeActionState)) + AND NOT EXISTS ( + SELECT 1 + FROM "mergeActions" ma + WHERE ma.type = $(mergeActionType) + AND ma.state <> $(mergeActionState) + AND ( + (ma."primaryId" = otm."organizationId" AND ma."secondaryId" = otm."toMergeId") + OR (ma."primaryId" = otm."toMergeId" AND ma."secondaryId" = otm."organizationId") + ) + ) `, { segmentId, From c387a885cfb4ed4f64bbda46631b10971bd699ac Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:33:59 +0530 Subject: [PATCH 07/11] fix: resolve pr review comments Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com> --- backend/src/database/repositories/memberRepository.ts | 10 ++++++++-- .../database/repositories/organizationRepository.ts | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/backend/src/database/repositories/memberRepository.ts b/backend/src/database/repositories/memberRepository.ts index abc5ea447e..c9691f8316 100644 --- a/backend/src/database/repositories/memberRepository.ts +++ b/backend/src/database/repositories/memberRepository.ts @@ -386,10 +386,16 @@ class MemberRepository { (s) => s.parentId == null && s.grandparentId == null, ) - if (projectGroupSegment && !hasCountFilters) { + if (!hasCountFilters) { + if (!projectGroupSegment) { + throw new Error( + 'A project group segment is required for unfiltered merge suggestion counts.', + ) + } + const counts = await getSegmentMergeSuggestionCounts( SequelizeRepository.getQueryExecutor(options), - projectGroupSegment?.id, + projectGroupSegment.id, ) return counts?.memberMergeSuggestionsCount ?? 0 diff --git a/backend/src/database/repositories/organizationRepository.ts b/backend/src/database/repositories/organizationRepository.ts index 3570925fc6..49da0f1b95 100644 --- a/backend/src/database/repositories/organizationRepository.ts +++ b/backend/src/database/repositories/organizationRepository.ts @@ -909,10 +909,16 @@ class OrganizationRepository { (s) => s.parentId == null && s.grandparentId == null, ) - if (projectGroupSegment && !hasCountFilters) { + if (!hasCountFilters) { + if (!projectGroupSegment) { + throw new Error( + 'A project group segment is required for unfiltered merge suggestion counts.', + ) + } + const counts = await getSegmentMergeSuggestionCounts( SequelizeRepository.getQueryExecutor(options), - projectGroupSegment?.id, + projectGroupSegment.id, ) return counts?.organizationMergeSuggestionsCount ?? 0 From c471ece81eb51fc4783f212f5da8d2aa03a7d313 Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:51:49 +0530 Subject: [PATCH 08/11] fix: add default value to updatedAt column in segmentMergeSuggestionCounts table Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com> --- .../migrations/V1782128525__segment-merge-suggestion-counts.sql | 2 +- backend/src/database/repositories/memberRepository.ts | 2 +- backend/src/database/repositories/organizationRepository.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/database/migrations/V1782128525__segment-merge-suggestion-counts.sql b/backend/src/database/migrations/V1782128525__segment-merge-suggestion-counts.sql index 6f3b9b0ff8..98ff0f2b80 100644 --- a/backend/src/database/migrations/V1782128525__segment-merge-suggestion-counts.sql +++ b/backend/src/database/migrations/V1782128525__segment-merge-suggestion-counts.sql @@ -2,5 +2,5 @@ create table "segmentMergeSuggestionCounts" ( "segmentId" uuid primary key references "segments" ("id") on delete cascade, "memberMergeSuggestionsCount" integer not null default 0, "organizationMergeSuggestionsCount" integer not null default 0, - "updatedAt" timestamp with time zone + "updatedAt" timestamp with time zone default now() not null ); \ No newline at end of file diff --git a/backend/src/database/repositories/memberRepository.ts b/backend/src/database/repositories/memberRepository.ts index c9691f8316..cc2c3ca7fe 100644 --- a/backend/src/database/repositories/memberRepository.ts +++ b/backend/src/database/repositories/memberRepository.ts @@ -407,8 +407,8 @@ class MemberRepository { displayNameFilter, { segmentIds, - memberId: args?.filter?.memberId, displayName: args?.filter?.displayName ? `${args.filter.displayName}%` : undefined, + memberId: args?.filter?.memberId, }, options, ) diff --git a/backend/src/database/repositories/organizationRepository.ts b/backend/src/database/repositories/organizationRepository.ts index 49da0f1b95..973eb79597 100644 --- a/backend/src/database/repositories/organizationRepository.ts +++ b/backend/src/database/repositories/organizationRepository.ts @@ -930,8 +930,8 @@ class OrganizationRepository { displayNameFilter, { segmentIds, - organizationId: args?.filter?.organizationId, displayName: args?.filter?.displayName ? `${args.filter.displayName}%` : undefined, + organizationId: args?.filter?.organizationId, }, options, ) From bff28b2f6a8b8ddb2092715baf521856e50e11ed Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:44:06 +0530 Subject: [PATCH 09/11] refactor: update segment handling in member and organization repositories to use single segment ID Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com> --- .../database/repositories/memberRepository.ts | 39 +++++-------------- .../repositories/organizationRepository.ts | 32 ++++++--------- .../repositories/sequelizeRepository.ts | 14 +++++++ .../services/contributor.api.service.ts | 8 +++- 4 files changed, 40 insertions(+), 53 deletions(-) diff --git a/backend/src/database/repositories/memberRepository.ts b/backend/src/database/repositories/memberRepository.ts index cc2c3ca7fe..e786e785fc 100644 --- a/backend/src/database/repositories/memberRepository.ts +++ b/backend/src/database/repositories/memberRepository.ts @@ -259,7 +259,7 @@ class MemberRepository { similarityFilter: string, displayNameFilter: string, replacements: { - segmentIds: string[] + segmentId: string memberId?: string displayName?: string }, @@ -278,11 +278,11 @@ class MemberRepository { ${membersJoin} WHERE EXISTS ( SELECT 1 FROM "memberSegmentsAgg" ms - WHERE ms."memberId" = mtm."memberId" AND ms."segmentId" IN (:segmentIds) + WHERE ms."memberId" = mtm."memberId" AND ms."segmentId" = :segmentId ) AND EXISTS ( SELECT 1 FROM "memberSegmentsAgg" ms2 - WHERE ms2."memberId" = mtm."toMergeId" AND ms2."segmentId" IN (:segmentIds) + WHERE ms2."memberId" = mtm."toMergeId" AND ms2."segmentId" = :segmentId ) AND NOT EXISTS ( SELECT 1 @@ -319,19 +319,8 @@ class MemberRepository { const MEDIUM_CONFIDENCE_LOWER_BOUND = 0.7 // Member segments are aggregated at each hierarchy level (group -> project -> subproject). - // Match the selected segment ID directly; do not expand to leaf subprojects. - const segmentIds = SequelizeRepository.getSegmentIds(options) - - if (segmentIds.length === 0) { - return args.countOnly - ? { count: 0 } - : { - rows: [{ members: [], similarity: 0 }], - count: 0, - limit: args.limit, - offset: args.offset, - } - } + // Merge suggestions are scoped to a single project group segment; do not expand to leaf subprojects. + const projectGroupSegment = SequelizeRepository.getStrictlySingleProjectGroupSegment(options) let similarityFilter = '' const similarityConditions = [] @@ -382,17 +371,7 @@ class MemberRepository { ) const getTotalCount = async (): Promise => { - const projectGroupSegment = options.currentSegments?.find( - (s) => s.parentId == null && s.grandparentId == null, - ) - if (!hasCountFilters) { - if (!projectGroupSegment) { - throw new Error( - 'A project group segment is required for unfiltered merge suggestion counts.', - ) - } - const counts = await getSegmentMergeSuggestionCounts( SequelizeRepository.getQueryExecutor(options), projectGroupSegment.id, @@ -406,7 +385,7 @@ class MemberRepository { similarityFilter, displayNameFilter, { - segmentIds, + segmentId: projectGroupSegment.id, displayName: args?.filter?.displayName ? `${args.filter.displayName}%` : undefined, memberId: args?.filter?.memberId, }, @@ -434,11 +413,11 @@ class MemberRepository { JOIN members m2 ON m2.id = mtm."toMergeId" WHERE EXISTS ( SELECT 1 FROM "memberSegmentsAgg" ms - WHERE ms."memberId" = mtm."memberId" AND ms."segmentId" IN (:segmentIds) + WHERE ms."memberId" = mtm."memberId" AND ms."segmentId" = :segmentId ) AND EXISTS ( SELECT 1 FROM "memberSegmentsAgg" ms2 - WHERE ms2."memberId" = mtm."toMergeId" AND ms2."segmentId" IN (:segmentIds) + WHERE ms2."memberId" = mtm."toMergeId" AND ms2."segmentId" = :segmentId ) AND NOT EXISTS ( SELECT 1 @@ -460,7 +439,7 @@ class MemberRepository { `, { replacements: { - segmentIds, + segmentId: projectGroupSegment.id, limit: args.limit, offset: args.offset, displayName: args?.filter?.displayName ? `${args.filter.displayName}%` : undefined, diff --git a/backend/src/database/repositories/organizationRepository.ts b/backend/src/database/repositories/organizationRepository.ts index 973eb79597..66fe784f27 100644 --- a/backend/src/database/repositories/organizationRepository.ts +++ b/backend/src/database/repositories/organizationRepository.ts @@ -797,7 +797,7 @@ class OrganizationRepository { similarityFilter: string, displayNameFilter: string, replacements: { - segmentIds: string[] + segmentId: string organizationId?: string displayName?: string }, @@ -815,11 +815,11 @@ class OrganizationRepository { ${organizationsJoin} WHERE EXISTS ( SELECT 1 FROM "organizationSegmentsAgg" os1 - WHERE os1."organizationId" = otm."organizationId" AND os1."segmentId" IN (:segmentIds) + WHERE os1."organizationId" = otm."organizationId" AND os1."segmentId" = :segmentId ) AND EXISTS ( SELECT 1 FROM "organizationSegmentsAgg" os2 - WHERE os2."organizationId" = otm."toMergeId" AND os2."segmentId" IN (:segmentIds) + WHERE os2."organizationId" = otm."toMergeId" AND os2."segmentId" = :segmentId ) AND NOT EXISTS ( SELECT 1 @@ -856,8 +856,8 @@ class OrganizationRepository { const MEDIUM_CONFIDENCE_LOWER_BOUND = 0.7 // Organization segments are aggregated at each hierarchy level (group -> project -> subproject). - // Match the selected segment ID(s) directly; do not expand to leaf subprojects. - const segmentIds = SequelizeRepository.getSegmentIds(options) + // Merge suggestions are scoped to a single project group segment; do not expand to leaf subprojects. + const projectGroupSegment = SequelizeRepository.getStrictlySingleProjectGroupSegment(options) let similarityFilter = '' const similarityConditions = [] @@ -905,17 +905,7 @@ class OrganizationRepository { ) const getTotalCount = async (): Promise => { - const projectGroupSegment = options.currentSegments?.find( - (s) => s.parentId == null && s.grandparentId == null, - ) - if (!hasCountFilters) { - if (!projectGroupSegment) { - throw new Error( - 'A project group segment is required for unfiltered merge suggestion counts.', - ) - } - const counts = await getSegmentMergeSuggestionCounts( SequelizeRepository.getQueryExecutor(options), projectGroupSegment.id, @@ -929,7 +919,7 @@ class OrganizationRepository { similarityFilter, displayNameFilter, { - segmentIds, + segmentId: projectGroupSegment.id, displayName: args?.filter?.displayName ? `${args.filter.displayName}%` : undefined, organizationId: args?.filter?.organizationId, }, @@ -952,21 +942,21 @@ class OrganizationRepository { o2."displayName" as "secondaryDisplayName", o2.logo as "secondaryLogo", (SELECT os1."segmentId" FROM "organizationSegmentsAgg" os1 - WHERE os1."organizationId" = otm."organizationId" AND os1."segmentId" IN (:segmentIds) + WHERE os1."organizationId" = otm."organizationId" AND os1."segmentId" = :segmentId LIMIT 1) as "primarySegmentId", (SELECT os2."segmentId" FROM "organizationSegmentsAgg" os2 - WHERE os2."organizationId" = otm."toMergeId" AND os2."segmentId" IN (:segmentIds) + WHERE os2."organizationId" = otm."toMergeId" AND os2."segmentId" = :segmentId LIMIT 1) as "secondarySegmentId" FROM "organizationToMerge" otm JOIN organizations o1 ON o1.id = otm."organizationId" JOIN organizations o2 ON o2.id = otm."toMergeId" WHERE EXISTS ( SELECT 1 FROM "organizationSegmentsAgg" os1 - WHERE os1."organizationId" = otm."organizationId" AND os1."segmentId" IN (:segmentIds) + WHERE os1."organizationId" = otm."organizationId" AND os1."segmentId" = :segmentId ) AND EXISTS ( SELECT 1 FROM "organizationSegmentsAgg" os2 - WHERE os2."organizationId" = otm."toMergeId" AND os2."segmentId" IN (:segmentIds) + WHERE os2."organizationId" = otm."toMergeId" AND os2."segmentId" = :segmentId ) AND NOT EXISTS ( SELECT 1 @@ -986,7 +976,7 @@ class OrganizationRepository { `, { replacements: { - segmentIds, + segmentId: projectGroupSegment.id, limit: args.limit, offset: args.offset, displayName: args?.filter?.displayName ? `${args.filter.displayName}%` : undefined, diff --git a/backend/src/database/repositories/sequelizeRepository.ts b/backend/src/database/repositories/sequelizeRepository.ts index f8cbe2d122..7c8b09dcf0 100644 --- a/backend/src/database/repositories/sequelizeRepository.ts +++ b/backend/src/database/repositories/sequelizeRepository.ts @@ -103,6 +103,20 @@ export default class SequelizeRepository { return options.currentSegments[0] } + static getStrictlySingleProjectGroupSegment( + options: IRepositoryOptions | IServiceOptions, + ): SegmentData { + const segment = this.getStrictlySingleActiveSegment(options) + + if (segment.parentId != null || segment.grandparentId != null) { + throw new Error400( + `This operation requires a project group segment. Segment ${segment.id} is not a project group.`, + ) + } + + return segment + } + /** * Returns the transaction if it exists on the options. */ diff --git a/frontend/src/modules/contributor/services/contributor.api.service.ts b/frontend/src/modules/contributor/services/contributor.api.service.ts index 983cec9f34..431794ca5b 100644 --- a/frontend/src/modules/contributor/services/contributor.api.service.ts +++ b/frontend/src/modules/contributor/services/contributor.api.service.ts @@ -1,6 +1,6 @@ +import { storeToRefs } from 'pinia'; import authAxios from '@/shared/axios/auth-axios'; import { Contributor } from '@/modules/contributor/types/Contributor'; -import { storeToRefs } from 'pinia'; import { useLfSegmentsStore } from '@/modules/lf/segments/store'; const getSegments = () => { @@ -25,10 +25,14 @@ export class ContributorApiService { } static async mergeSuggestions(limit: number, offset: number, query: any, segments: string[]) { + const resolvedSegments = segments.length + ? segments + : (getSegments() ?? []); + const data = { limit, offset, - segments, + segments: resolvedSegments, detail: true, ...query, }; From 245b9a0e9e1df8cdb1c698adb2c92ca0aa1ba880 Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:45:58 +0530 Subject: [PATCH 10/11] fix: restore project and subproject filtering for merge suggestions Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com> --- .../database/repositories/memberRepository.ts | 31 +++++++++++----- .../repositories/organizationRepository.ts | 35 +++++++++++++------ 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/backend/src/database/repositories/memberRepository.ts b/backend/src/database/repositories/memberRepository.ts index e786e785fc..f8746a24dd 100644 --- a/backend/src/database/repositories/memberRepository.ts +++ b/backend/src/database/repositories/memberRepository.ts @@ -259,7 +259,7 @@ class MemberRepository { similarityFilter: string, displayNameFilter: string, replacements: { - segmentId: string + segmentIds: string[] memberId?: string displayName?: string }, @@ -278,11 +278,11 @@ class MemberRepository { ${membersJoin} WHERE EXISTS ( SELECT 1 FROM "memberSegmentsAgg" ms - WHERE ms."memberId" = mtm."memberId" AND ms."segmentId" = :segmentId + WHERE ms."memberId" = mtm."memberId" AND ms."segmentId" IN (:segmentIds) ) AND EXISTS ( SELECT 1 FROM "memberSegmentsAgg" ms2 - WHERE ms2."memberId" = mtm."toMergeId" AND ms2."segmentId" = :segmentId + WHERE ms2."memberId" = mtm."toMergeId" AND ms2."segmentId" IN (:segmentIds) ) AND NOT EXISTS ( SELECT 1 @@ -319,9 +319,18 @@ class MemberRepository { const MEDIUM_CONFIDENCE_LOWER_BOUND = 0.7 // Member segments are aggregated at each hierarchy level (group -> project -> subproject). - // Merge suggestions are scoped to a single project group segment; do not expand to leaf subprojects. const projectGroupSegment = SequelizeRepository.getStrictlySingleProjectGroupSegment(options) + let segmentIds: string[] + + if (args.filter?.projectIds?.length) { + segmentIds = args.filter.projectIds + } else if (args.filter?.subprojectIds?.length) { + segmentIds = args.filter.subprojectIds + } else { + segmentIds = [projectGroupSegment.id] + } + let similarityFilter = '' const similarityConditions = [] @@ -366,12 +375,16 @@ class MemberRepository { order += 'mtm."memberId", mtm."toMergeId"' } + const hasProjectFilter = Boolean( + args.filter?.projectIds?.length || args.filter?.subprojectIds?.length, + ) + const hasCountFilters = Boolean( args.filter?.memberId || args.filter?.displayName || args.filter?.similarity?.length, ) const getTotalCount = async (): Promise => { - if (!hasCountFilters) { + if (!hasCountFilters && !hasProjectFilter) { const counts = await getSegmentMergeSuggestionCounts( SequelizeRepository.getQueryExecutor(options), projectGroupSegment.id, @@ -385,7 +398,7 @@ class MemberRepository { similarityFilter, displayNameFilter, { - segmentId: projectGroupSegment.id, + segmentIds, displayName: args?.filter?.displayName ? `${args.filter.displayName}%` : undefined, memberId: args?.filter?.memberId, }, @@ -413,11 +426,11 @@ class MemberRepository { JOIN members m2 ON m2.id = mtm."toMergeId" WHERE EXISTS ( SELECT 1 FROM "memberSegmentsAgg" ms - WHERE ms."memberId" = mtm."memberId" AND ms."segmentId" = :segmentId + WHERE ms."memberId" = mtm."memberId" AND ms."segmentId" IN (:segmentIds) ) AND EXISTS ( SELECT 1 FROM "memberSegmentsAgg" ms2 - WHERE ms2."memberId" = mtm."toMergeId" AND ms2."segmentId" = :segmentId + WHERE ms2."memberId" = mtm."toMergeId" AND ms2."segmentId" IN (:segmentIds) ) AND NOT EXISTS ( SELECT 1 @@ -439,7 +452,7 @@ class MemberRepository { `, { replacements: { - segmentId: projectGroupSegment.id, + segmentIds, limit: args.limit, offset: args.offset, displayName: args?.filter?.displayName ? `${args.filter.displayName}%` : undefined, diff --git a/backend/src/database/repositories/organizationRepository.ts b/backend/src/database/repositories/organizationRepository.ts index 66fe784f27..916701ee22 100644 --- a/backend/src/database/repositories/organizationRepository.ts +++ b/backend/src/database/repositories/organizationRepository.ts @@ -797,7 +797,7 @@ class OrganizationRepository { similarityFilter: string, displayNameFilter: string, replacements: { - segmentId: string + segmentIds: string[] organizationId?: string displayName?: string }, @@ -815,11 +815,11 @@ class OrganizationRepository { ${organizationsJoin} WHERE EXISTS ( SELECT 1 FROM "organizationSegmentsAgg" os1 - WHERE os1."organizationId" = otm."organizationId" AND os1."segmentId" = :segmentId + WHERE os1."organizationId" = otm."organizationId" AND os1."segmentId" IN (:segmentIds) ) AND EXISTS ( SELECT 1 FROM "organizationSegmentsAgg" os2 - WHERE os2."organizationId" = otm."toMergeId" AND os2."segmentId" = :segmentId + WHERE os2."organizationId" = otm."toMergeId" AND os2."segmentId" IN (:segmentIds) ) AND NOT EXISTS ( SELECT 1 @@ -856,9 +856,18 @@ class OrganizationRepository { const MEDIUM_CONFIDENCE_LOWER_BOUND = 0.7 // Organization segments are aggregated at each hierarchy level (group -> project -> subproject). - // Merge suggestions are scoped to a single project group segment; do not expand to leaf subprojects. const projectGroupSegment = SequelizeRepository.getStrictlySingleProjectGroupSegment(options) + let segmentIds: string[] + + if (args.filter?.projectIds?.length) { + segmentIds = args.filter.projectIds + } else if (args.filter?.subprojectIds?.length) { + segmentIds = args.filter.subprojectIds + } else { + segmentIds = [projectGroupSegment.id] + } + let similarityFilter = '' const similarityConditions = [] @@ -900,12 +909,16 @@ class OrganizationRepository { order += 'otm."organizationId", otm."toMergeId"' } + const hasProjectFilter = Boolean( + args.filter?.projectIds?.length || args.filter?.subprojectIds?.length, + ) + const hasCountFilters = Boolean( args.filter?.organizationId || args.filter?.displayName || args.filter?.similarity?.length, ) const getTotalCount = async (): Promise => { - if (!hasCountFilters) { + if (!hasCountFilters && !hasProjectFilter) { const counts = await getSegmentMergeSuggestionCounts( SequelizeRepository.getQueryExecutor(options), projectGroupSegment.id, @@ -919,7 +932,7 @@ class OrganizationRepository { similarityFilter, displayNameFilter, { - segmentId: projectGroupSegment.id, + segmentIds, displayName: args?.filter?.displayName ? `${args.filter.displayName}%` : undefined, organizationId: args?.filter?.organizationId, }, @@ -942,21 +955,21 @@ class OrganizationRepository { o2."displayName" as "secondaryDisplayName", o2.logo as "secondaryLogo", (SELECT os1."segmentId" FROM "organizationSegmentsAgg" os1 - WHERE os1."organizationId" = otm."organizationId" AND os1."segmentId" = :segmentId + WHERE os1."organizationId" = otm."organizationId" AND os1."segmentId" IN (:segmentIds) LIMIT 1) as "primarySegmentId", (SELECT os2."segmentId" FROM "organizationSegmentsAgg" os2 - WHERE os2."organizationId" = otm."toMergeId" AND os2."segmentId" = :segmentId + WHERE os2."organizationId" = otm."toMergeId" AND os2."segmentId" IN (:segmentIds) LIMIT 1) as "secondarySegmentId" FROM "organizationToMerge" otm JOIN organizations o1 ON o1.id = otm."organizationId" JOIN organizations o2 ON o2.id = otm."toMergeId" WHERE EXISTS ( SELECT 1 FROM "organizationSegmentsAgg" os1 - WHERE os1."organizationId" = otm."organizationId" AND os1."segmentId" = :segmentId + WHERE os1."organizationId" = otm."organizationId" AND os1."segmentId" IN (:segmentIds) ) AND EXISTS ( SELECT 1 FROM "organizationSegmentsAgg" os2 - WHERE os2."organizationId" = otm."toMergeId" AND os2."segmentId" = :segmentId + WHERE os2."organizationId" = otm."toMergeId" AND os2."segmentId" IN (:segmentIds) ) AND NOT EXISTS ( SELECT 1 @@ -976,7 +989,7 @@ class OrganizationRepository { `, { replacements: { - segmentId: projectGroupSegment.id, + segmentIds, limit: args.limit, offset: args.offset, displayName: args?.filter?.displayName ? `${args.filter.displayName}%` : undefined, From 2fc787ceef7cff14dcc0b58dd1adb7701cdf6453 Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:08:17 +0530 Subject: [PATCH 11/11] fix: remove unnecessary null check for similarity in member repository query Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com> --- backend/src/database/repositories/memberRepository.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/database/repositories/memberRepository.ts b/backend/src/database/repositories/memberRepository.ts index f8746a24dd..38ae2e67dd 100644 --- a/backend/src/database/repositories/memberRepository.ts +++ b/backend/src/database/repositories/memberRepository.ts @@ -442,7 +442,6 @@ class MemberRepository { OR (ma."primaryId" = mtm."toMergeId" AND ma."secondaryId" = mtm."memberId") ) ) - AND mtm.similarity IS NOT NULL ${memberFilter} ${similarityFilter} ${displayNameFilter}