Skip to content

Commit 16c13f0

Browse files
authored
feat: expose organization domains in public APIs (CM-1266) (#4315)
Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
1 parent 658f21d commit 16c13f0

12 files changed

Lines changed: 230 additions & 75 deletions

File tree

backend/src/api/public/openapi.yaml

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -779,8 +779,11 @@ paths:
779779
/organizations:
780780
get:
781781
operationId: getOrganization
782-
summary: Look up an organization by domain
783-
description: Find a verified organization by its primary domain.
782+
summary: Look up an organization by domain or name
783+
description: >
784+
Provide domain, name, or both. When both are provided, the domain and
785+
name must belong to the same organization. If multiple organizations
786+
match, the most active one is returned.
784787
tags:
785788
- Organizations
786789
security:
@@ -789,12 +792,20 @@ paths:
789792
parameters:
790793
- name: domain
791794
in: query
792-
required: true
795+
required: false
793796
description: Primary domain of the organization.
794797
schema:
795798
type: string
796799
minLength: 1
797800
example: linuxfoundation.org
801+
- name: name
802+
in: query
803+
required: false
804+
description: Exact display name of the organization.
805+
schema:
806+
type: string
807+
minLength: 1
808+
example: Linux Foundation
798809
responses:
799810
'200':
800811
description: Organization found.
@@ -805,13 +816,14 @@ paths:
805816
example:
806817
id: 550e8400-e29b-41d4-a716-446655440000
807818
name: Linux Foundation
819+
domain: linuxfoundation.org
808820
logo: https://example.com/logo.png
809821
'401':
810822
$ref: '#/components/responses/Unauthorized'
811823
'403':
812824
$ref: '#/components/responses/Forbidden'
813825
'404':
814-
description: No verified organization found for the given domain.
826+
description: No organization found for the given domain or name.
815827
content:
816828
application/json:
817829
schema:
@@ -876,15 +888,26 @@ paths:
876888
required:
877889
- id
878890
- name
891+
- domain
879892
properties:
880893
id:
881894
type: string
882895
format: uuid
883896
name:
884897
type: string
898+
domain:
899+
type: string
900+
description: Verified primary domain of the organization.
901+
logo:
902+
type:
903+
- string
904+
- 'null'
905+
description: URL of the organization logo.
885906
example:
886907
id: 550e8400-e29b-41d4-a716-446655440000
887908
name: Acme Corp
909+
domain: acme.com
910+
logo: https://example.com/logo.png
888911
'400':
889912
$ref: '#/components/responses/BadRequest'
890913
'401':
@@ -1218,6 +1241,7 @@ components:
12181241
- id
12191242
- organizationId
12201243
- organizationName
1244+
- organizationDomains
12211245
- jobTitle
12221246
- verified
12231247
- verifiedBy
@@ -1243,6 +1267,11 @@ components:
12431267
- string
12441268
- 'null'
12451269
description: URL of the organization logo.
1270+
organizationDomains:
1271+
type: array
1272+
items:
1273+
type: string
1274+
description: Verified primary domains for the organization, in alphabetical order.
12461275
jobTitle:
12471276
type:
12481277
- string
@@ -1531,13 +1560,17 @@ components:
15311560
required:
15321561
- id
15331562
- name
1563+
- domain
15341564
properties:
15351565
id:
15361566
type: string
15371567
format: uuid
15381568
name:
15391569
type: string
15401570
description: Display name of the organization.
1571+
domain:
1572+
type: string
1573+
description: Verified primary domain.
15411574
logo:
15421575
type: string
15431576
description: URL of the organization logo. Only present if available.

backend/src/api/public/v1/members/work-experiences/createMemberWorkExperience.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export async function createMemberWorkExperience(req: Request, res: Response): P
117117
memberOrganizationIds: [data.organizationId],
118118
})
119119

120-
const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId])
120+
const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId], { withDomains: true })
121121
createdMo = (orgsMap.get(memberId) ?? []).find((mo) => mo.id === newMemberOrgId)
122122

123123
captureNewState(createdMo ?? null)

backend/src/api/public/v1/members/work-experiences/getMemberWorkExperiences.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export async function getMemberWorkExperiences(req: Request, res: Response): Pro
2727
throw new NotFoundError('Member not found')
2828
}
2929

30-
const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId])
30+
const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId], { withDomains: true })
3131
const workExperiences = groupMemberOrganizations(orgsMap.get(memberId) ?? []).map(
3232
toMemberWorkExperience,
3333
)

backend/src/api/public/v1/members/work-experiences/updateMemberWorkExperience.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ export async function updateMemberWorkExperience(req: Request, res: Response): P
120120
memberOrganizationIds: [data.organizationId],
121121
})
122122

123-
const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId])
123+
const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId], { withDomains: true })
124124

125125
const updatedMo = groupMemberOrganizations(orgsMap.get(memberId) ?? []).find(
126126
(mo) => mo.id === workExperienceId,

backend/src/api/public/v1/members/work-experiences/verifyMemberWorkExperience.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,18 @@ export async function verifyMemberWorkExperience(req: Request, res: Response): P
5252
throw new NotFoundError('Work experience not found')
5353
}
5454

55+
const orgsMapBeforeChange = await fetchManyMemberOrgsWithOrgData(qx, [memberId], {
56+
withDomains: true,
57+
})
58+
59+
const workExperienceWithOrgData = groupMemberOrganizations(
60+
orgsMapBeforeChange.get(memberId) ?? [],
61+
).find((mo) => mo.id === workExperienceId)
62+
63+
if (!workExperienceWithOrgData) {
64+
throw new NotFoundError('Work experience not found')
65+
}
66+
5567
const overlappingGroupedRows = getOverlappingGroupedMemberOrganizations(memberOrgs, memberOrg)
5668

5769
const memberOrgIdsToDelete = [
@@ -100,13 +112,16 @@ export async function verifyMemberWorkExperience(req: Request, res: Response): P
100112
}),
101113
)
102114

103-
const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId])
115+
const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId], { withDomains: true })
104116

105-
const responseMo: IMemberRoleWithOrganization =
106-
groupMemberOrganizations(orgsMap.get(memberId) ?? []).find(
107-
(mo) => mo.id === workExperienceId,
108-
) ??
109-
({ ...memberOrg, ...updatedMemberOrg, verified, verifiedBy } as IMemberRoleWithOrganization)
117+
const responseMo: IMemberRoleWithOrganization = groupMemberOrganizations(
118+
orgsMap.get(memberId) ?? [],
119+
).find((mo) => mo.id === workExperienceId) ?? {
120+
...workExperienceWithOrgData,
121+
...updatedMemberOrg,
122+
verified,
123+
verifiedBy,
124+
}
110125

111126
ok(res, toMemberWorkExperience(responseMo))
112127
}

backend/src/api/public/v1/organizations/createOrganization.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Request, Response } from 'express'
22
import { z } from 'zod'
33

44
import { captureApiChange, organizationCreateAction } from '@crowd/audit-logs'
5-
import { InternalError } from '@crowd/common'
5+
import { BadRequestError, InternalError, normalizeHostname } from '@crowd/common'
66
import { findOrCreateOrganization, optionsQx } from '@crowd/data-access-layer'
77
import { OrganizationAttributeSource, OrganizationIdentityType } from '@crowd/types'
88

@@ -17,16 +17,20 @@ const bodySchema = z.object({
1717
})
1818

1919
export async function createOrganization(req: Request, res: Response): Promise<void> {
20-
const { name, domain, source, logo } = validateOrThrow(bodySchema, req.body)
20+
const { name, domain: rawDomain, source, logo } = validateOrThrow(bodySchema, req.body)
2121

22-
const qx = optionsQx(req)
22+
const domain = normalizeHostname(rawDomain, false)
23+
24+
if (!domain) {
25+
throw new BadRequestError(`Invalid domain: ${rawDomain}`)
26+
}
2327

24-
let organizationId: string | undefined
28+
const qx = optionsQx(req)
2529

26-
await qx.tx(async (tx) => {
30+
const organizationId = await qx.tx(async (tx) => {
2731
const orgSource = OrganizationAttributeSource.LFX_SERVE
2832

29-
organizationId = await findOrCreateOrganization(tx, orgSource, {
33+
const organizationId = await findOrCreateOrganization(tx, orgSource, {
3034
displayName: name,
3135
logo,
3236
identities: [
@@ -59,7 +63,9 @@ export async function createOrganization(req: Request, res: Response): Promise<v
5963
})
6064
}),
6165
)
66+
67+
return organizationId
6268
})
6369

64-
created(res, { id: organizationId, name, logo })
70+
created(res, { id: organizationId, name, logo, domain })
6571
}
Lines changed: 23 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,41 @@
11
import type { Request, Response } from 'express'
22
import { z } from 'zod'
33

4-
import { NotFoundError, normalizeHostname } from '@crowd/common'
5-
import {
6-
OrgIdentityField,
7-
OrganizationField,
8-
findOrgAttributes,
9-
findOrgById,
10-
optionsQx,
11-
queryOrgIdentities,
12-
} from '@crowd/data-access-layer'
13-
import { OrganizationIdentityType } from '@crowd/types'
4+
import { BadRequestError, NotFoundError, normalizeHostname } from '@crowd/common'
5+
import { findOrganizationByNameOrDomain, optionsQx } from '@crowd/data-access-layer'
146

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

18-
const querySchema = z.object({
19-
domain: z.string().trim().min(1),
20-
})
10+
const querySchema = z
11+
.object({
12+
name: z.string().trim().min(1).optional(),
13+
domain: z.string().trim().min(1).optional(),
14+
})
15+
.refine((data) => data.name || data.domain, {
16+
message: 'Either name or domain must be provided',
17+
})
2118

2219
export async function getOrganization(req: Request, res: Response): Promise<void> {
23-
const { domain } = validateOrThrow(querySchema, req.query)
20+
const { name, domain: rawDomain } = validateOrThrow(querySchema, req.query)
21+
22+
const domain = rawDomain ? normalizeHostname(rawDomain, false) : undefined
23+
24+
if (rawDomain && !domain) {
25+
throw new BadRequestError(`Invalid domain: ${rawDomain}`)
26+
}
2427

2528
const qx = optionsQx(req)
2629

27-
const results = await queryOrgIdentities(qx, {
28-
fields: [OrgIdentityField.ORGANIZATION_ID],
29-
filter: {
30-
and: [
31-
{ value: { eq: normalizeHostname(domain, false) } },
32-
{ type: { eq: OrganizationIdentityType.PRIMARY_DOMAIN } },
33-
{ verified: { eq: true } },
34-
],
35-
},
30+
const organization = await findOrganizationByNameOrDomain(qx, {
31+
name,
32+
domain,
3633
})
3734

38-
const organizationId = results[0]?.organizationId
39-
40-
if (!organizationId) {
35+
if (!organization) {
4136
throw new NotFoundError('Organization not found')
4237
}
4338

44-
const org = await findOrgById(qx, organizationId, [
45-
OrganizationField.ID,
46-
OrganizationField.DISPLAY_NAME,
47-
])
48-
49-
const attributes = await findOrgAttributes(qx, organizationId)
50-
const logo = attributes.find((a) => a.name === 'logo')?.value
51-
52-
ok(res, {
53-
id: org.id,
54-
name: org.displayName,
55-
...(logo ? { logo } : {}),
56-
})
39+
const { logo, ...rest } = organization
40+
ok(res, { ...rest, ...(logo ? { logo } : {}) })
5741
}

backend/src/utils/mapper.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ export function toMemberWorkExperience(mo: IMemberRoleWithOrganization) {
182182
organizationId: mo.organizationId,
183183
organizationName: mo.organizationName,
184184
organizationLogo: mo.organizationLogo,
185+
organizationDomains: mo.organizationDomains ?? [],
185186
jobTitle: mo.title ?? null,
186187
verified: mo.verified ?? false,
187188
verifiedBy: mo.verifiedBy ?? null,

services/libs/common/src/domain.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ interface ParseResult {
1414
}
1515

1616
export const cleanURL = (url: string): string =>
17-
url.replace(/^(?:https?:\/\/)?(?:www\.)?([^/]+)(?:\/.*)?$/, '$1')
17+
url.replace(/^(?:https?:\/\/)?(?:www\.)?([^/]+)(?:\/.*)?$/i, '$1')
1818

1919
export const isValidDomain = (parsed: ParseResult): boolean =>
2020
Boolean(parsed.domain && parsed.isIcann)

0 commit comments

Comments
 (0)