Skip to content
41 changes: 37 additions & 4 deletions backend/src/api/public/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -1218,6 +1241,7 @@ components:
- id
- organizationId
- organizationName
- organizationDomains
- jobTitle
- verified
- verifiedBy
Expand All @@ -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
Expand Down Expand Up @@ -1531,13 +1560,17 @@ components:
required:
- id
- name
- domain
properties:
id:
type: string
format: uuid
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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))
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,5 @@ export async function createOrganization(req: Request, res: Response): Promise<v
)
})

created(res, { id: organizationId, name, logo })
created(res, { id: organizationId, name, logo, domain })
Comment thread
skwowet marked this conversation as resolved.
}
55 changes: 16 additions & 39 deletions backend/src/api/public/v1/organizations/getOrganization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,56 +2,33 @@ import type { Request, Response } from 'express'
import { z } from 'zod'

import { NotFoundError, normalizeHostname } from '@crowd/common'
import {
OrgIdentityField,
OrganizationField,
findOrgAttributes,
findOrgById,
optionsQx,
queryOrgIdentities,
} from '@crowd/data-access-layer'
import { OrganizationIdentityType } from '@crowd/types'
import { findOrganizationByNameOrDomain, optionsQx } from '@crowd/data-access-layer'

import { ok } from '@/utils/api'
import { validateOrThrow } from '@/utils/validation'

const querySchema = z.object({
domain: z.string().trim().min(1),
})
const querySchema = z
.object({
name: z.string().trim().min(1).optional(),
domain: z.string().trim().min(1).optional(),
})
.refine((data) => data.name || data.domain, {
message: 'Either name or domain must be provided',
})

export async function getOrganization(req: Request, res: Response): Promise<void> {
const { domain } = validateOrThrow(querySchema, req.query)

const { name, domain } = validateOrThrow(querySchema, req.query)
const qx = optionsQx(req)

const results = await queryOrgIdentities(qx, {
fields: [OrgIdentityField.ORGANIZATION_ID],
filter: {
and: [
{ value: { eq: normalizeHostname(domain, false) } },
{ type: { eq: OrganizationIdentityType.PRIMARY_DOMAIN } },
{ verified: { eq: true } },
],
},
const organization = await findOrganizationByNameOrDomain(qx, {
name,
domain: domain ? normalizeHostname(domain, false) : undefined,
})
Comment thread
skwowet marked this conversation as resolved.
Comment thread
skwowet marked this conversation as resolved.

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 } : {}) })
}
1 change: 1 addition & 0 deletions backend/src/utils/mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
66 changes: 51 additions & 15 deletions services/libs/data-access-layer/src/members/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,27 +213,63 @@ export async function fetchManyMemberOrgs(
export async function fetchManyMemberOrgsWithOrgData(
qx: QueryExecutor,
memberIds: string[],
{ withDomains = false }: { withDomains?: boolean } = {},
): Promise<Map<string, IMemberRoleWithOrganization[]>> {
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, '{}') 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<string, IMemberRoleWithOrganization[]>()

const resultMap = new Map<string, IMemberRoleWithOrganization[]>()
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(
Expand Down
Loading
Loading