diff --git a/backend/src/api/public/openapi.yaml b/backend/src/api/public/openapi.yaml index 0a3089ca9a..3be905cd7c 100644 --- a/backend/src/api/public/openapi.yaml +++ b/backend/src/api/public/openapi.yaml @@ -779,8 +779,11 @@ paths: /organizations: get: operationId: getOrganization - summary: Look up an organization by domain - description: Find a verified organization by its primary domain. + summary: Look up an organization by domain or name + description: > + Provide domain, name, or both. When both are provided, the domain and + name must belong to the same organization. If multiple organizations + match, the most active one is returned. tags: - Organizations security: @@ -789,12 +792,20 @@ paths: parameters: - name: domain in: query - required: true + required: false description: Primary domain of the organization. schema: type: string minLength: 1 example: linuxfoundation.org + - name: name + in: query + required: false + description: Exact display name of the organization. + schema: + type: string + minLength: 1 + example: Linux Foundation responses: '200': description: Organization found. @@ -805,13 +816,14 @@ paths: example: id: 550e8400-e29b-41d4-a716-446655440000 name: Linux Foundation + domain: linuxfoundation.org logo: https://example.com/logo.png '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': - description: No verified organization found for the given domain. + description: No organization found for the given domain or name. content: application/json: schema: @@ -876,15 +888,26 @@ paths: required: - id - name + - domain properties: id: type: string format: uuid name: type: string + domain: + type: string + description: Verified primary domain of the organization. + logo: + type: + - string + - 'null' + description: URL of the organization logo. example: id: 550e8400-e29b-41d4-a716-446655440000 name: Acme Corp + domain: acme.com + logo: https://example.com/logo.png '400': $ref: '#/components/responses/BadRequest' '401': @@ -1218,6 +1241,7 @@ components: - id - organizationId - organizationName + - organizationDomains - jobTitle - verified - verifiedBy @@ -1243,6 +1267,11 @@ components: - string - 'null' description: URL of the organization logo. + organizationDomains: + type: array + items: + type: string + description: Verified primary domains for the organization, in alphabetical order. jobTitle: type: - string @@ -1531,6 +1560,7 @@ components: required: - id - name + - domain properties: id: type: string @@ -1538,6 +1568,9 @@ components: name: type: string description: Display name of the organization. + domain: + type: string + description: Verified primary domain. logo: type: string description: URL of the organization logo. Only present if available. diff --git a/backend/src/api/public/v1/members/work-experiences/createMemberWorkExperience.ts b/backend/src/api/public/v1/members/work-experiences/createMemberWorkExperience.ts index 86c8d7e8c7..f84e7d259c 100644 --- a/backend/src/api/public/v1/members/work-experiences/createMemberWorkExperience.ts +++ b/backend/src/api/public/v1/members/work-experiences/createMemberWorkExperience.ts @@ -117,7 +117,7 @@ export async function createMemberWorkExperience(req: Request, res: Response): P memberOrganizationIds: [data.organizationId], }) - const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId]) + const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId], { withDomains: true }) createdMo = (orgsMap.get(memberId) ?? []).find((mo) => mo.id === newMemberOrgId) captureNewState(createdMo ?? null) diff --git a/backend/src/api/public/v1/members/work-experiences/getMemberWorkExperiences.ts b/backend/src/api/public/v1/members/work-experiences/getMemberWorkExperiences.ts index c253f49f3b..ed000b68a4 100644 --- a/backend/src/api/public/v1/members/work-experiences/getMemberWorkExperiences.ts +++ b/backend/src/api/public/v1/members/work-experiences/getMemberWorkExperiences.ts @@ -27,7 +27,7 @@ export async function getMemberWorkExperiences(req: Request, res: Response): Pro throw new NotFoundError('Member not found') } - const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId]) + const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId], { withDomains: true }) const workExperiences = groupMemberOrganizations(orgsMap.get(memberId) ?? []).map( toMemberWorkExperience, ) diff --git a/backend/src/api/public/v1/members/work-experiences/updateMemberWorkExperience.ts b/backend/src/api/public/v1/members/work-experiences/updateMemberWorkExperience.ts index 444fc75cad..62ce2277f0 100644 --- a/backend/src/api/public/v1/members/work-experiences/updateMemberWorkExperience.ts +++ b/backend/src/api/public/v1/members/work-experiences/updateMemberWorkExperience.ts @@ -120,7 +120,7 @@ export async function updateMemberWorkExperience(req: Request, res: Response): P memberOrganizationIds: [data.organizationId], }) - const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId]) + const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId], { withDomains: true }) const updatedMo = groupMemberOrganizations(orgsMap.get(memberId) ?? []).find( (mo) => mo.id === workExperienceId, diff --git a/backend/src/api/public/v1/members/work-experiences/verifyMemberWorkExperience.ts b/backend/src/api/public/v1/members/work-experiences/verifyMemberWorkExperience.ts index f55a68a7c1..650e3ad92c 100644 --- a/backend/src/api/public/v1/members/work-experiences/verifyMemberWorkExperience.ts +++ b/backend/src/api/public/v1/members/work-experiences/verifyMemberWorkExperience.ts @@ -52,6 +52,18 @@ export async function verifyMemberWorkExperience(req: Request, res: Response): P throw new NotFoundError('Work experience not found') } + const orgsMapBeforeChange = await fetchManyMemberOrgsWithOrgData(qx, [memberId], { + withDomains: true, + }) + + const workExperienceWithOrgData = groupMemberOrganizations( + orgsMapBeforeChange.get(memberId) ?? [], + ).find((mo) => mo.id === workExperienceId) + + if (!workExperienceWithOrgData) { + throw new NotFoundError('Work experience not found') + } + const overlappingGroupedRows = getOverlappingGroupedMemberOrganizations(memberOrgs, memberOrg) const memberOrgIdsToDelete = [ @@ -100,13 +112,16 @@ export async function verifyMemberWorkExperience(req: Request, res: Response): P }), ) - const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId]) + const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId], { withDomains: true }) - const responseMo: IMemberRoleWithOrganization = - groupMemberOrganizations(orgsMap.get(memberId) ?? []).find( - (mo) => mo.id === workExperienceId, - ) ?? - ({ ...memberOrg, ...updatedMemberOrg, verified, verifiedBy } as IMemberRoleWithOrganization) + const responseMo: IMemberRoleWithOrganization = groupMemberOrganizations( + orgsMap.get(memberId) ?? [], + ).find((mo) => mo.id === workExperienceId) ?? { + ...workExperienceWithOrgData, + ...updatedMemberOrg, + verified, + verifiedBy, + } ok(res, toMemberWorkExperience(responseMo)) } diff --git a/backend/src/api/public/v1/organizations/createOrganization.ts b/backend/src/api/public/v1/organizations/createOrganization.ts index d8f47714a1..2ae7b3abae 100644 --- a/backend/src/api/public/v1/organizations/createOrganization.ts +++ b/backend/src/api/public/v1/organizations/createOrganization.ts @@ -2,7 +2,7 @@ import type { Request, Response } from 'express' import { z } from 'zod' import { captureApiChange, organizationCreateAction } from '@crowd/audit-logs' -import { InternalError } from '@crowd/common' +import { BadRequestError, InternalError, normalizeHostname } from '@crowd/common' import { findOrCreateOrganization, optionsQx } from '@crowd/data-access-layer' import { OrganizationAttributeSource, OrganizationIdentityType } from '@crowd/types' @@ -17,16 +17,20 @@ const bodySchema = z.object({ }) export async function createOrganization(req: Request, res: Response): Promise { - const { name, domain, source, logo } = validateOrThrow(bodySchema, req.body) + const { name, domain: rawDomain, source, logo } = validateOrThrow(bodySchema, req.body) - const qx = optionsQx(req) + const domain = normalizeHostname(rawDomain, false) + + if (!domain) { + throw new BadRequestError(`Invalid domain: ${rawDomain}`) + } - let organizationId: string | undefined + const qx = optionsQx(req) - await qx.tx(async (tx) => { + const organizationId = await qx.tx(async (tx) => { const orgSource = OrganizationAttributeSource.LFX_SERVE - organizationId = await findOrCreateOrganization(tx, orgSource, { + const organizationId = await findOrCreateOrganization(tx, orgSource, { displayName: name, logo, identities: [ @@ -59,7 +63,9 @@ export async function createOrganization(req: Request, res: Response): Promise data.name || data.domain, { + message: 'Either name or domain must be provided', + }) export async function getOrganization(req: Request, res: Response): Promise { - const { domain } = validateOrThrow(querySchema, req.query) + const { name, domain: rawDomain } = validateOrThrow(querySchema, req.query) + + const domain = rawDomain ? normalizeHostname(rawDomain, false) : undefined + + if (rawDomain && !domain) { + throw new BadRequestError(`Invalid domain: ${rawDomain}`) + } const qx = optionsQx(req) - const results = await queryOrgIdentities(qx, { - fields: [OrgIdentityField.ORGANIZATION_ID], - filter: { - and: [ - { value: { eq: normalizeHostname(domain, false) } }, - { type: { eq: OrganizationIdentityType.PRIMARY_DOMAIN } }, - { verified: { eq: true } }, - ], - }, + const organization = await findOrganizationByNameOrDomain(qx, { + name, + domain, }) - const organizationId = results[0]?.organizationId - - if (!organizationId) { + if (!organization) { throw new NotFoundError('Organization not found') } - const org = await findOrgById(qx, organizationId, [ - OrganizationField.ID, - OrganizationField.DISPLAY_NAME, - ]) - - const attributes = await findOrgAttributes(qx, organizationId) - const logo = attributes.find((a) => a.name === 'logo')?.value - - ok(res, { - id: org.id, - name: org.displayName, - ...(logo ? { logo } : {}), - }) + const { logo, ...rest } = organization + ok(res, { ...rest, ...(logo ? { logo } : {}) }) } diff --git a/backend/src/utils/mapper.ts b/backend/src/utils/mapper.ts index 02e072f4e2..d4f7e57c9a 100644 --- a/backend/src/utils/mapper.ts +++ b/backend/src/utils/mapper.ts @@ -182,6 +182,7 @@ export function toMemberWorkExperience(mo: IMemberRoleWithOrganization) { organizationId: mo.organizationId, organizationName: mo.organizationName, organizationLogo: mo.organizationLogo, + organizationDomains: mo.organizationDomains ?? [], jobTitle: mo.title ?? null, verified: mo.verified ?? false, verifiedBy: mo.verifiedBy ?? null, diff --git a/services/libs/common/src/domain.ts b/services/libs/common/src/domain.ts index c215bc7b2f..77cd10b6cd 100644 --- a/services/libs/common/src/domain.ts +++ b/services/libs/common/src/domain.ts @@ -14,7 +14,7 @@ interface ParseResult { } export const cleanURL = (url: string): string => - url.replace(/^(?:https?:\/\/)?(?:www\.)?([^/]+)(?:\/.*)?$/, '$1') + url.replace(/^(?:https?:\/\/)?(?:www\.)?([^/]+)(?:\/.*)?$/i, '$1') export const isValidDomain = (parsed: ParseResult): boolean => Boolean(parsed.domain && parsed.isIcann) diff --git a/services/libs/data-access-layer/src/members/organizations.ts b/services/libs/data-access-layer/src/members/organizations.ts index debe4b21dc..594ed21654 100644 --- a/services/libs/data-access-layer/src/members/organizations.ts +++ b/services/libs/data-access-layer/src/members/organizations.ts @@ -213,27 +213,63 @@ export async function fetchManyMemberOrgs( export async function fetchManyMemberOrgsWithOrgData( qx: QueryExecutor, memberIds: string[], + { withDomains = false }: { withDomains?: boolean } = {}, ): Promise> { - const memberRoles = (await qx.select( - ` - SELECT mo.*, o."displayName" as "organizationName", o.logo as "organizationLogo" - FROM "memberOrganizations" mo - join "organizations" o on mo."organizationId" = o.id - WHERE mo."memberId" in ($(memberIds:csv)) + const domainSelect = withDomains + ? `, + COALESCE(oid.domains, '{}'::text[]) AS "organizationDomains"` + : '' + + const domainJoin = withDomains + ? ` + LEFT JOIN ( + SELECT + oi."organizationId", + array_agg(DISTINCT lower(oi.value) ORDER BY lower(oi.value)) AS domains + FROM "organizationIdentities" oi + WHERE oi.type = 'primary-domain' + AND oi.verified = true + AND oi."organizationId" IN ( + SELECT DISTINCT mo2."organizationId" + FROM "memberOrganizations" mo2 + WHERE mo2."memberId" IN ($(memberIds:csv)) + AND mo2."deletedAt" IS NULL + ) + GROUP BY oi."organizationId" + ) oid ON oid."organizationId" = mo."organizationId"` + : '' + + const sql = ` + SELECT + mo.*, + o."displayName" AS "organizationName", + o.logo AS "organizationLogo" + ${domainSelect} + FROM "memberOrganizations" mo + JOIN organizations o ON o.id = mo."organizationId" + ${domainJoin} + WHERE mo."memberId" IN ($(memberIds:csv)) AND mo."deletedAt" IS NULL; - `, - { - memberIds, - }, - )) as IMemberRoleWithOrganization[] + ` + + const memberRoles = (await qx.select(sql, { + memberIds, + })) as IMemberRoleWithOrganization[] + + const result = new Map() - const resultMap = new Map() for (const memberId of memberIds) { - const roles = memberRoles.filter((r) => r.memberId === memberId) - resultMap.set(memberId, roles) + result.set(memberId, []) } - return resultMap + for (const role of memberRoles) { + const roles = result.get(role.memberId) + if (roles) { + roles.push(role) + } + } + + return result } export async function fetchManyOrganizationAffiliationPolicies( diff --git a/services/libs/data-access-layer/src/organizations/base.ts b/services/libs/data-access-layer/src/organizations/base.ts index a638938364..3323ba09fc 100644 --- a/services/libs/data-access-layer/src/organizations/base.ts +++ b/services/libs/data-access-layer/src/organizations/base.ts @@ -728,3 +728,82 @@ export async function findNonExistingOrganizationIds( return rows.map((r: { id: string }) => r.id) } + +type OrganizationSummary = Pick & { + name: string + domain: string +} + +export async function findOrganizationByNameOrDomain( + qx: QueryExecutor, + { name, domain }: { name?: string; domain?: string }, +): Promise { + if (!name && !domain) { + return null + } + + const domainJoin = domain + ? ` + INNER JOIN "organizationIdentities" oi + ON oi."organizationId" = o.id + AND oi.type = 'primary-domain' + AND oi.verified = true + AND lower(oi.value) = lower($(domain))` + : '' + + const filters = ['o."deletedAt" IS NULL'] + + if (name) { + filters.push( + `trim(lower(o."displayName")) = trim(lower($(name)))`, + `EXISTS ( + SELECT 1 + FROM "organizationIdentities" oi_check + WHERE oi_check."organizationId" = o.id + AND oi_check.type = 'primary-domain' + AND oi_check.verified = true + )`, + ) + } + + const domainSelect = domain + ? 'lower(oi.value) AS domain' + : `( + SELECT lower(oi_domain.value) + FROM "organizationIdentities" oi_domain + WHERE oi_domain."organizationId" = o.id + AND oi_domain.type = 'primary-domain' + AND oi_domain.verified = true + ORDER BY lower(oi_domain.value) + LIMIT 1 + ) AS domain` + + const sql = ` + SELECT + o.id, + o."displayName" AS name, + o.logo, + ${domainSelect} + FROM "organizations" o + ${domainJoin} + LEFT JOIN "organizationsGlobalActivityCount" gac + ON gac."organizationId" = o.id + WHERE + ${filters.join(' AND ')} + ORDER BY + COALESCE(gac.total_count_estimate, 0) DESC, + ( + SELECT COUNT(DISTINCT mo."memberId") + FROM "memberOrganizations" mo + WHERE mo."organizationId" = o.id + AND mo."deletedAt" IS NULL + ) DESC, + o.id + LIMIT 1; + ` + + return qx.selectOneOrNone(sql, { + name, + domain, + }) +} diff --git a/services/libs/types/src/organizations.ts b/services/libs/types/src/organizations.ts index 40769253a6..85674161f8 100644 --- a/services/libs/types/src/organizations.ts +++ b/services/libs/types/src/organizations.ts @@ -94,6 +94,7 @@ export interface IRenderFriendlyMemberOrganization { export interface IMemberRoleWithOrganization extends IMemberOrganization { organizationName: string organizationLogo: string + organizationDomains?: string[] } export interface MemberOrgDate {