From a21d47d9802bee27ee16ee39dcfec37cad366dc2 Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:35:25 +0530 Subject: [PATCH 1/3] feat: match LF project orgs beyond exact name (CM-1199) Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com> --- backend/src/services/organizationService.ts | 11 ++-- backend/src/services/segmentService.ts | 9 ++-- services/libs/common/src/index.ts | 1 + services/libs/common/src/organization.ts | 53 +++++++++++++++++++ .../src/organizations/base.ts | 32 +++++------ .../data-access-layer/src/segments/index.ts | 21 +++++--- 6 files changed, 98 insertions(+), 29 deletions(-) create mode 100644 services/libs/common/src/organization.ts diff --git a/backend/src/services/organizationService.ts b/backend/src/services/organizationService.ts index 0ea279ec16..91ce70068f 100644 --- a/backend/src/services/organizationService.ts +++ b/backend/src/services/organizationService.ts @@ -6,7 +6,7 @@ import { organizationMergeAction, organizationUnmergeAction, } from '@crowd/audit-logs' -import { Error400, Error404, Error409, mergeObjects, normalizeHostname } from '@crowd/common' +import { Error400, Error404, Error409, generateOrganizationNameVariants, mergeObjects, normalizeHostname } from '@crowd/common' import { unmergeRoles } from '@crowd/common_services' import { addMemberRole, @@ -31,7 +31,7 @@ import { } from '@crowd/data-access-layer/src/organizations' import { decrementOrganizationMergeSuggestionCounts, - findLfSegmentByName, + findManyLfSegmentsByNames, getOrganizationsCommonProjectGroupSegmentIds, } from '@crowd/data-access-layer/src/segments' import { LoggerBase } from '@crowd/logging' @@ -927,8 +927,11 @@ export default class OrganizationService extends LoggerBase { if (data.displayName) { // Block organization affiliation if a LF segment (project, subproject, or project group) // has the same name as the organization when creating one. - const lfSegment = await findLfSegmentByName(qx, data.displayName) - if (lfSegment) { + const lfSegments = await findManyLfSegmentsByNames( + qx, + generateOrganizationNameVariants(data.displayName), + ) + if (lfSegments.length > 0) { this.log.info( { displayName: data.displayName }, 'Found segment with the same name as the organization, blocking affiliation!', diff --git a/backend/src/services/segmentService.ts b/backend/src/services/segmentService.ts index b70ae5b951..e2deccfab2 100644 --- a/backend/src/services/segmentService.ts +++ b/backend/src/services/segmentService.ts @@ -1,9 +1,9 @@ import { Transaction } from 'sequelize' -import { Error400, validateNonLfSlug } from '@crowd/common' +import { Error400, generateOrganizationNameVariants, validateNonLfSlug } from '@crowd/common' import { QueryExecutor, - findOrganizationsByName, + findManyOrganizationsByNames, updateOrganization, } from '@crowd/data-access-layer' import { ICreateInsightsProject, findBySlug } from '@crowd/data-access-layer/src/collections' @@ -727,7 +727,10 @@ export default class SegmentService extends LoggerBase { }) // Check if there is an existing organization with segment name - const organizations = await findOrganizationsByName(qx, segmentName) + const organizations = await findManyOrganizationsByNames( + qx, + generateOrganizationNameVariants(segmentName), + ) if (organizations.length === 0) { return [] diff --git a/services/libs/common/src/index.ts b/services/libs/common/src/index.ts index 6a4eb89884..ec521de4f9 100644 --- a/services/libs/common/src/index.ts +++ b/services/libs/common/src/index.ts @@ -35,6 +35,7 @@ export * from './rawQueryParser' export * from './byteLength' export * from './domain' export * from './displayName' +export * from './organization' export * from './country' export * from './jira' export * from './email' diff --git a/services/libs/common/src/organization.ts b/services/libs/common/src/organization.ts new file mode 100644 index 0000000000..2903bc85ed --- /dev/null +++ b/services/libs/common/src/organization.ts @@ -0,0 +1,53 @@ +export function generateOrganizationNameVariants(name: string): string[] { + const exact = name.trim().toLowerCase().replace(/\s+/g, ' ') + if (!exact) { + return [] + } + + const variants = new Set([exact]) + const add = (value: string) => { + const normalized = value.trim().toLowerCase().replace(/\s+/g, ' ') + if (normalized) { + variants.add(normalized) + } + } + + const withoutParens = exact.replace(/\s*\([^)]*\)\s*$/, '').trim() + if (withoutParens !== exact && withoutParens.length >= 8) { + add(withoutParens) + } + + for (const value of [...variants]) { + if (value.startsWith('the ') && value.slice(4).length >= 8) { + add(value.slice(4)) + } + } + + for (const value of [...variants]) { + for (const suffix of ['project', 'foundation', 'initiative']) { + const token = ` ${suffix}` + if (value.endsWith(token)) { + const base = value.slice(0, -token.length).trim() + if (base.length >= 6) { + add(base) + } + } else if (value.length >= 4 && !value.includes('(')) { + add(`${value}${token}`) + } + } + } + + for (const value of [...variants]) { + if (value.includes('-')) { + add(value.replace(/-/g, ' ')) + } + if (value.includes(' ')) { + add(value.replace(/ /g, '-')) + } + if (value.includes('.')) { + add(value.replace(/\./g, '')) + } + } + + return [...variants] +} diff --git a/services/libs/data-access-layer/src/organizations/base.ts b/services/libs/data-access-layer/src/organizations/base.ts index 28f929b22e..ee5b76a346 100644 --- a/services/libs/data-access-layer/src/organizations/base.ts +++ b/services/libs/data-access-layer/src/organizations/base.ts @@ -3,6 +3,7 @@ import { UnrepeatableError, generateUUIDv1, normalizeHostname, + generateOrganizationNameVariants, } from '@crowd/common' import { getServiceChildLogger, logExecutionTimeV2 } from '@crowd/logging' import { @@ -18,7 +19,7 @@ import { } from '@crowd/types' import { QueryExecutor } from '../queryExecutor' -import { findLfSegmentByName } from '../segments' +import { findManyLfSegmentsByNames } from '../segments' import { QueryOptions, QueryResult, prepareBulkInsert, queryTable, queryTableById } from '../utils' import { prepareSelectColumns } from '../utils' @@ -131,24 +132,22 @@ export async function findOrgsByIds( return results } -export async function findOrganizationsByName( +export async function findManyOrganizationsByNames( qx: QueryExecutor, - name: string, - options: { limit?: number } = {}, + names: string[], ): Promise { - const { limit } = options + if (names.length === 0) { + return [] + } return qx.select( ` select ${prepareSelectColumns(ORG_SELECT_COLUMNS, 'o')} from organizations o - where lower(trim(o."displayName")) = lower(trim($(name))) - ${limit !== undefined ? 'limit $(limit)' : ''} + where o."deletedAt" is null + and trim(lower(o."displayName")) in ($(names:csv)) `, - { - name, - limit, - }, + { names }, ) } @@ -584,9 +583,9 @@ export async function findOrCreateOrganization( if (!existing) { const organizations = await logExecutionTimeV2( - async () => findOrganizationsByName(qe, data.displayName, { limit: 1 }), + async () => findManyOrganizationsByNames(qe, [data.displayName]), log, - 'organizationService -> findOrCreateOrganization -> findOrganizationsByName', + 'organizationService -> findOrCreateOrganization -> findManyOrganizationsByNames', ) if (organizations.length > 0) { @@ -673,8 +672,11 @@ export async function findOrCreateOrganization( // Block organization affiliation if a segment (project, subproject, or project group) // has the same name as the organization when creating one. - const lfSegment = await findLfSegmentByName(qe, displayName) - if (lfSegment) { + const lfSegments = await findManyLfSegmentsByNames( + qe, + generateOrganizationNameVariants(displayName), + ) + if (lfSegments.length > 0) { payload.isAffiliationBlocked = true } diff --git a/services/libs/data-access-layer/src/segments/index.ts b/services/libs/data-access-layer/src/segments/index.ts index 7fbd056a3d..c25e8861aa 100644 --- a/services/libs/data-access-layer/src/segments/index.ts +++ b/services/libs/data-access-layer/src/segments/index.ts @@ -34,19 +34,26 @@ export async function findProjectGroupByName( ) } -export async function findLfSegmentByName( +export async function findManyLfSegmentsByNames( qx: QueryExecutor, - name: string, -): Promise { - return qx.selectOneOrNone( + names: string[], +): Promise { + if (names.length === 0) { + return [] + } + + return qx.select( ` SELECT * FROM segments WHERE "isLF" = true - AND trim(lower(name)) = trim(lower($(name))) - LIMIT 1; + AND ( + trim(lower(name)) IN ($(names:csv)) + OR trim(both FROM regexp_replace(trim(lower(name)), '\\s*\\([^)]*\\)\\s*$', '')) + IN ($(names:csv)) + ) `, - { name }, + { names }, ) } From ba0b1efd7117868aa6c99d493de59c2da4bc5f93 Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:48:02 +0530 Subject: [PATCH 2/3] fix: satisfy prettier and drop paren-strip regex (CM-1199) Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com> --- backend/src/services/organizationService.ts | 9 ++++++++- services/libs/common/src/organization.ts | 8 +++++++- .../libs/data-access-layer/src/organizations/base.ts | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/backend/src/services/organizationService.ts b/backend/src/services/organizationService.ts index 91ce70068f..b7011f1fdb 100644 --- a/backend/src/services/organizationService.ts +++ b/backend/src/services/organizationService.ts @@ -6,7 +6,14 @@ import { organizationMergeAction, organizationUnmergeAction, } from '@crowd/audit-logs' -import { Error400, Error404, Error409, generateOrganizationNameVariants, mergeObjects, normalizeHostname } from '@crowd/common' +import { + Error400, + Error404, + Error409, + generateOrganizationNameVariants, + mergeObjects, + normalizeHostname, +} from '@crowd/common' import { unmergeRoles } from '@crowd/common_services' import { addMemberRole, diff --git a/services/libs/common/src/organization.ts b/services/libs/common/src/organization.ts index 2903bc85ed..c86bc65516 100644 --- a/services/libs/common/src/organization.ts +++ b/services/libs/common/src/organization.ts @@ -12,7 +12,13 @@ export function generateOrganizationNameVariants(name: string): string[] { } } - const withoutParens = exact.replace(/\s*\([^)]*\)\s*$/, '').trim() + let withoutParens = exact + if (exact.endsWith(')')) { + const open = exact.lastIndexOf('(') + if (open !== -1 && !exact.slice(open + 1, -1).includes(')')) { + withoutParens = exact.slice(0, open).trimEnd() + } + } if (withoutParens !== exact && withoutParens.length >= 8) { add(withoutParens) } diff --git a/services/libs/data-access-layer/src/organizations/base.ts b/services/libs/data-access-layer/src/organizations/base.ts index ee5b76a346..fc385d59f7 100644 --- a/services/libs/data-access-layer/src/organizations/base.ts +++ b/services/libs/data-access-layer/src/organizations/base.ts @@ -1,9 +1,9 @@ import { DEFAULT_TENANT_ID, UnrepeatableError, + generateOrganizationNameVariants, generateUUIDv1, normalizeHostname, - generateOrganizationNameVariants, } from '@crowd/common' import { getServiceChildLogger, logExecutionTimeV2 } from '@crowd/logging' import { From d4315a35e5a1eab1ab1c7e75778b837f652fde24 Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:57:11 +0530 Subject: [PATCH 3/3] fix: lowercase name lookups so mixed-case dedup still hits (CM-1199) Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com> --- services/libs/data-access-layer/src/organizations/base.ts | 5 +++-- services/libs/data-access-layer/src/segments/index.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/services/libs/data-access-layer/src/organizations/base.ts b/services/libs/data-access-layer/src/organizations/base.ts index fc385d59f7..9eca37b3b7 100644 --- a/services/libs/data-access-layer/src/organizations/base.ts +++ b/services/libs/data-access-layer/src/organizations/base.ts @@ -136,7 +136,8 @@ export async function findManyOrganizationsByNames( qx: QueryExecutor, names: string[], ): Promise { - if (names.length === 0) { + const normalized = names.map((name) => name.trim().toLowerCase()).filter(Boolean) + if (normalized.length === 0) { return [] } @@ -147,7 +148,7 @@ export async function findManyOrganizationsByNames( where o."deletedAt" is null and trim(lower(o."displayName")) in ($(names:csv)) `, - { names }, + { names: normalized }, ) } diff --git a/services/libs/data-access-layer/src/segments/index.ts b/services/libs/data-access-layer/src/segments/index.ts index c25e8861aa..9c2791b529 100644 --- a/services/libs/data-access-layer/src/segments/index.ts +++ b/services/libs/data-access-layer/src/segments/index.ts @@ -38,7 +38,8 @@ export async function findManyLfSegmentsByNames( qx: QueryExecutor, names: string[], ): Promise { - if (names.length === 0) { + const normalized = names.map((name) => name.trim().toLowerCase()).filter(Boolean) + if (normalized.length === 0) { return [] } @@ -53,7 +54,7 @@ export async function findManyLfSegmentsByNames( IN ($(names:csv)) ) `, - { names }, + { names: normalized }, ) }