diff --git a/backend/src/services/organizationService.ts b/backend/src/services/organizationService.ts index 0ea279ec16..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, 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 +38,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 +934,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..c86bc65516 --- /dev/null +++ b/services/libs/common/src/organization.ts @@ -0,0 +1,59 @@ +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) + } + } + + 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) + } + + 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..9eca37b3b7 100644 --- a/services/libs/data-access-layer/src/organizations/base.ts +++ b/services/libs/data-access-layer/src/organizations/base.ts @@ -1,6 +1,7 @@ import { DEFAULT_TENANT_ID, UnrepeatableError, + generateOrganizationNameVariants, generateUUIDv1, normalizeHostname, } from '@crowd/common' @@ -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,23 @@ 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 + const normalized = names.map((name) => name.trim().toLowerCase()).filter(Boolean) + if (normalized.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: normalized }, ) } @@ -584,9 +584,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 +673,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..9c2791b529 100644 --- a/services/libs/data-access-layer/src/segments/index.ts +++ b/services/libs/data-access-layer/src/segments/index.ts @@ -34,19 +34,27 @@ export async function findProjectGroupByName( ) } -export async function findLfSegmentByName( +export async function findManyLfSegmentsByNames( qx: QueryExecutor, - name: string, -): Promise { - return qx.selectOneOrNone( + names: string[], +): Promise { + const normalized = names.map((name) => name.trim().toLowerCase()).filter(Boolean) + if (normalized.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: normalized }, ) }