Skip to content

Commit dd4cdaf

Browse files
joanagmaiaclaude
andcommitted
feat: expose org domains via work-history and org name search (CM-1266)
- Add domains[] to work-experience responses via bulk primary-domain lookup - Extend GET /organizations to accept ?name= with fuzzy, paginated search - Add searchOrganizationsByName DAL fn with ILIKE + window-count - Update openapi.yaml with new fields, params, and OrganizationSearchResponse schema - Add Vitest unit tests for searchOrganizationsByName Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: Joana Maia <jmaia@contractor.linuxfoundation.org>
1 parent 278e61b commit dd4cdaf

6 files changed

Lines changed: 307 additions & 41 deletions

File tree

backend/src/api/public/openapi.yaml

Lines changed: 112 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -779,8 +779,14 @@ 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 or search organizations
783+
description: >
784+
Look up a single verified organization by its primary domain (`domain`),
785+
or search organizations by display name (`name`). Exactly one of the two
786+
query parameters must be provided.
787+
788+
When `name` is provided the response is a paginated list; when `domain`
789+
is provided the response is a single organization object.
784790
tags:
785791
- Organizations
786792
security:
@@ -789,23 +795,70 @@ paths:
789795
parameters:
790796
- name: domain
791797
in: query
792-
required: true
793-
description: Primary domain of the organization.
798+
required: false
799+
description: Primary domain of the organization. Mutually exclusive with `name`.
794800
schema:
795801
type: string
796802
minLength: 1
797803
example: linuxfoundation.org
804+
- name: name
805+
in: query
806+
required: false
807+
description: >
808+
Display name to search for (case-insensitive substring match).
809+
Mutually exclusive with `domain`.
810+
schema:
811+
type: string
812+
minLength: 1
813+
example: Linux Foundation
814+
- name: page
815+
in: query
816+
required: false
817+
description: Page number (1-based). Only used when `name` is provided.
818+
schema:
819+
type: integer
820+
minimum: 1
821+
default: 1
822+
- name: pageSize
823+
in: query
824+
required: false
825+
description: Number of results per page (max 100). Only used when `name` is provided.
826+
schema:
827+
type: integer
828+
minimum: 1
829+
maximum: 100
830+
default: 20
798831
responses:
799832
'200':
800-
description: Organization found.
833+
description: >
834+
Organization found (domain lookup) or search results returned (name search).
801835
content:
802836
application/json:
803837
schema:
804-
$ref: '#/components/schemas/Organization'
805-
example:
806-
id: 550e8400-e29b-41d4-a716-446655440000
807-
name: Linux Foundation
808-
logo: https://example.com/logo.png
838+
oneOf:
839+
- $ref: '#/components/schemas/Organization'
840+
- $ref: '#/components/schemas/OrganizationSearchResponse'
841+
examples:
842+
domainLookup:
843+
summary: Single organization by domain
844+
value:
845+
id: 550e8400-e29b-41d4-a716-446655440000
846+
name: Linux Foundation
847+
logo: https://example.com/logo.png
848+
nameSearch:
849+
summary: Paginated name search results
850+
value:
851+
organizations:
852+
- id: 550e8400-e29b-41d4-a716-446655440000
853+
name: Linux Foundation
854+
domains:
855+
- linuxfoundation.org
856+
logo: https://example.com/logo.png
857+
page: 1
858+
pageSize: 20
859+
total: 1
860+
'400':
861+
$ref: '#/components/responses/BadRequest'
809862
'401':
810863
$ref: '#/components/responses/Unauthorized'
811864
'403':
@@ -1243,6 +1296,11 @@ components:
12431296
- string
12441297
- 'null'
12451298
description: URL of the organization logo.
1299+
domains:
1300+
type: array
1301+
items:
1302+
type: string
1303+
description: Verified primary domains for the organization. Empty when none are on record.
12461304
jobTitle:
12471305
type:
12481306
- string
@@ -1542,6 +1600,50 @@ components:
15421600
type: string
15431601
description: URL of the organization logo. Only present if available.
15441602

1603+
OrganizationWithDomains:
1604+
type: object
1605+
required:
1606+
- id
1607+
- name
1608+
- domains
1609+
properties:
1610+
id:
1611+
type: string
1612+
format: uuid
1613+
name:
1614+
type: string
1615+
description: Display name of the organization.
1616+
domains:
1617+
type: array
1618+
items:
1619+
type: string
1620+
description: Verified primary domains for the organization.
1621+
logo:
1622+
type: string
1623+
description: URL of the organization logo. Only present if available.
1624+
1625+
OrganizationSearchResponse:
1626+
type: object
1627+
required:
1628+
- organizations
1629+
- page
1630+
- pageSize
1631+
- total
1632+
properties:
1633+
organizations:
1634+
type: array
1635+
items:
1636+
$ref: '#/components/schemas/OrganizationWithDomains'
1637+
page:
1638+
type: integer
1639+
description: Current page number (1-based).
1640+
pageSize:
1641+
type: integer
1642+
description: Number of results per page.
1643+
total:
1644+
type: integer
1645+
description: Total number of matching organizations.
1646+
15451647
HttpError:
15461648
type: object
15471649
required:

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { NotFoundError } from '@crowd/common'
55
import {
66
MemberField,
77
fetchManyMemberOrgsWithOrgData,
8+
fetchManyOrganizationVerifiedPrimaryDomains,
89
findMemberById,
910
optionsQx,
1011
} from '@crowd/data-access-layer'
@@ -28,8 +29,14 @@ export async function getMemberWorkExperiences(req: Request, res: Response): Pro
2829
}
2930

3031
const orgsMap = await fetchManyMemberOrgsWithOrgData(qx, [memberId])
31-
const workExperiences = groupMemberOrganizations(orgsMap.get(memberId) ?? []).map(
32-
toMemberWorkExperience,
32+
const grouped = groupMemberOrganizations(orgsMap.get(memberId) ?? [])
33+
34+
const orgIds = [...new Set(grouped.map((r) => r.organizationId).filter(Boolean))]
35+
const primaryDomains = await fetchManyOrganizationVerifiedPrimaryDomains(qx, orgIds)
36+
const domainsMap = new Map(primaryDomains.map((d) => [d.orgId, d.domains]))
37+
38+
const workExperiences = grouped.map((role) =>
39+
toMemberWorkExperience(role, domainsMap.get(role.organizationId) ?? []),
3340
)
3441

3542
ok(res, { memberId, workExperiences })

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

Lines changed: 63 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,53 +5,88 @@ import { NotFoundError, normalizeHostname } from '@crowd/common'
55
import {
66
OrgIdentityField,
77
OrganizationField,
8+
fetchManyOrganizationVerifiedPrimaryDomains,
89
findOrgAttributes,
910
findOrgById,
1011
optionsQx,
1112
queryOrgIdentities,
13+
searchOrganizationsByName,
1214
} from '@crowd/data-access-layer'
1315
import { OrganizationIdentityType } from '@crowd/types'
1416

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

18-
const querySchema = z.object({
19-
domain: z.string().trim().min(1),
20-
})
20+
const DEFAULT_PAGE_SIZE = 20
21+
const MAX_PAGE_SIZE = 100
22+
23+
const querySchema = z
24+
.object({
25+
domain: z.string().trim().min(1).optional(),
26+
name: z.string().trim().min(1).optional(),
27+
page: z.coerce.number().int().min(1).default(1),
28+
pageSize: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE),
29+
})
30+
.refine((d) => !!(d.domain ?? d.name), {
31+
message: 'Either domain or name is required',
32+
})
33+
.refine((d) => !(d.domain && d.name), {
34+
message: 'Only one of domain or name may be provided',
35+
})
2136

2237
export async function getOrganization(req: Request, res: Response): Promise<void> {
23-
const { domain } = validateOrThrow(querySchema, req.query)
38+
const { domain, name, page, pageSize } = validateOrThrow(querySchema, req.query)
2439

2540
const qx = optionsQx(req)
2641

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-
},
36-
})
42+
if (domain) {
43+
const results = await queryOrgIdentities(qx, {
44+
fields: [OrgIdentityField.ORGANIZATION_ID],
45+
filter: {
46+
and: [
47+
{ value: { eq: normalizeHostname(domain, false) } },
48+
{ type: { eq: OrganizationIdentityType.PRIMARY_DOMAIN } },
49+
{ verified: { eq: true } },
50+
],
51+
},
52+
})
3753

38-
const organizationId = results[0]?.organizationId
54+
const organizationId = results[0]?.organizationId
3955

40-
if (!organizationId) {
41-
throw new NotFoundError('Organization not found')
56+
if (!organizationId) {
57+
throw new NotFoundError('Organization not found')
58+
}
59+
60+
const org = await findOrgById(qx, organizationId, [
61+
OrganizationField.ID,
62+
OrganizationField.DISPLAY_NAME,
63+
])
64+
65+
const attributes = await findOrgAttributes(qx, organizationId)
66+
const logo = attributes.find((a) => a.name === 'logo')?.value
67+
68+
ok(res, {
69+
id: org.id,
70+
name: org.displayName,
71+
...(logo ? { logo } : {}),
72+
})
73+
return
4274
}
4375

44-
const org = await findOrgById(qx, organizationId, [
45-
OrganizationField.ID,
46-
OrganizationField.DISPLAY_NAME,
47-
])
76+
// name search — fuzzy, paginated
77+
const offset = (page - 1) * pageSize
78+
const { rows, total } = await searchOrganizationsByName(qx, name, { limit: pageSize, offset })
4879

49-
const attributes = await findOrgAttributes(qx, organizationId)
50-
const logo = attributes.find((a) => a.name === 'logo')?.value
80+
const orgIds = rows.map((r) => r.id)
81+
const primaryDomains = await fetchManyOrganizationVerifiedPrimaryDomains(qx, orgIds)
82+
const domainsMap = new Map(primaryDomains.map((d) => [d.orgId, d.domains]))
5183

52-
ok(res, {
53-
id: org.id,
54-
name: org.displayName,
55-
...(logo ? { logo } : {}),
56-
})
84+
const organizations = rows.map((r) => ({
85+
id: r.id,
86+
name: r.displayName,
87+
domains: domainsMap.get(r.id) ?? [],
88+
...(r.logo ? { logo: r.logo } : {}),
89+
}))
90+
91+
ok(res, { organizations, page, pageSize, total })
5792
}

backend/src/utils/mapper.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,13 @@ export function groupMemberOrganizations<T extends IMemberOrganization>(rows: T[
123123
})
124124
}
125125

126-
export function toMemberWorkExperience(mo: IMemberRoleWithOrganization) {
126+
export function toMemberWorkExperience(mo: IMemberRoleWithOrganization, domains: string[] = []) {
127127
return {
128128
id: mo.id,
129129
organizationId: mo.organizationId,
130130
organizationName: mo.organizationName,
131131
organizationLogo: mo.organizationLogo,
132+
domains,
132133
jobTitle: mo.title ?? null,
133134
verified: mo.verified ?? false,
134135
verifiedBy: mo.verifiedBy ?? null,

0 commit comments

Comments
 (0)