Skip to content

Commit 29c468f

Browse files
committed
Merge branch 'main' into fix/identity-issue-CM-1054
2 parents c04aa7e + bfc1bae commit 29c468f

32 files changed

Lines changed: 964 additions & 107 deletions

File tree

backend/src/api/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as http from 'http'
77
import os from 'os'
88
import { QueryTypes } from 'sequelize'
99

10+
import { BadRequestError } from '@crowd/common'
1011
import { getDbConnection } from '@crowd/data-access-layer/src/database'
1112
import { getServiceLogger } from '@crowd/logging'
1213
import { getOpensearchClient } from '@crowd/opensearch'
@@ -147,6 +148,14 @@ setImmediate(async () => {
147148

148149
app.use(bodyParser.urlencoded({ limit: '5mb', extended: true }))
149150

151+
app.use((err: any, req: any, res: any, next: any) => {
152+
if (err.type === 'entity.parse.failed') {
153+
next(new BadRequestError('Invalid JSON body'))
154+
return
155+
}
156+
next(err)
157+
})
158+
150159
app.use((req, res, next) => {
151160
// @ts-ignore
152161
req.userData = {

backend/src/api/public/index.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,12 @@
11
import { Router } from 'express'
22

3-
import { AUTH0_CONFIG } from '../../conf'
4-
53
import { errorHandler } from './middlewares/errorHandler'
6-
import { oauth2Middleware } from './middlewares/oauth2Middleware'
7-
import { staticApiKeyMiddleware } from './middlewares/staticApiKeyMiddleware'
84
import { v1Router } from './v1'
9-
import { devStatsRouter } from './v1/dev-stats'
105

116
export function publicRouter(): Router {
127
const router = Router()
138

14-
router.use('/v1/dev-stats', staticApiKeyMiddleware(), devStatsRouter())
15-
router.use('/v1', oauth2Middleware(AUTH0_CONFIG), v1Router())
9+
router.use('/v1', v1Router())
1610
router.use(errorHandler)
1711

1812
return router

backend/src/api/public/middlewares/errorHandler.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,13 @@ export const errorHandler: ErrorRequestHandler = (
3535
}
3636

3737
req.log.error(
38-
{ error, url: req.url, method: req.method, query: req.query, body: req.body },
38+
{
39+
error: { name: error?.name, message: error?.message, stack: error?.stack },
40+
url: req.url,
41+
method: req.method,
42+
query: req.query,
43+
body: req.body,
44+
},
3945
'Unhandled error in public API',
4046
)
4147

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { Request, Response } from 'express'
2+
3+
import { NotFoundError } from '@crowd/common'
4+
import {
5+
findMembersByGithubHandles,
6+
findVerifiedEmailsByMemberIds,
7+
optionsQx,
8+
resolveAffiliationsByMemberIds,
9+
} from '@crowd/data-access-layer'
10+
11+
import { ok } from '@/utils/api'
12+
13+
export async function getAffiliationByHandle(req: Request, res: Response): Promise<void> {
14+
const handle = req.params.githubHandle.toLowerCase()
15+
const qx = optionsQx(req)
16+
17+
const members = await findMembersByGithubHandles(qx, [handle])
18+
if (members.length === 0) {
19+
throw new NotFoundError(`No LFX profile found for GitHub login '${req.params.githubHandle}'.`)
20+
}
21+
22+
const member = members[0]
23+
const memberIds = [member.memberId]
24+
25+
const [emailRows, affiliationsByMember] = await Promise.all([
26+
findVerifiedEmailsByMemberIds(qx, memberIds),
27+
resolveAffiliationsByMemberIds(qx, memberIds),
28+
])
29+
30+
ok(res, {
31+
githubHandle: member.githubHandle,
32+
name: member.displayName,
33+
emails: emailRows.map((r) => r.email),
34+
affiliations: affiliationsByMember.get(member.memberId) ?? [],
35+
})
36+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import type { Request, Response } from 'express'
2+
import { z } from 'zod'
3+
4+
import {
5+
findMembersByGithubHandles,
6+
findVerifiedEmailsByMemberIds,
7+
optionsQx,
8+
resolveAffiliationsByMemberIds,
9+
} from '@crowd/data-access-layer'
10+
11+
import { ok } from '@/utils/api'
12+
import { validateOrThrow } from '@/utils/validation'
13+
14+
const MAX_HANDLES = 100
15+
const DEFAULT_PAGE_SIZE = 20
16+
17+
const bodySchema = z.object({
18+
githubHandles: z
19+
.array(z.string().trim().min(1).toLowerCase())
20+
.min(1)
21+
.max(MAX_HANDLES, `Maximum ${MAX_HANDLES} handles per request`),
22+
})
23+
24+
const querySchema = z.object({
25+
page: z.coerce.number().int().min(1).default(1),
26+
pageSize: z.coerce.number().int().min(1).max(MAX_HANDLES).default(DEFAULT_PAGE_SIZE),
27+
})
28+
29+
export async function getAffiliations(req: Request, res: Response): Promise<void> {
30+
const { githubHandles } = validateOrThrow(bodySchema, req.body)
31+
const { page, pageSize } = validateOrThrow(querySchema, req.query)
32+
const qx = optionsQx(req)
33+
34+
const offset = (page - 1) * pageSize
35+
36+
// Step 1: find all verified members across all handles
37+
const allMemberRows = await findMembersByGithubHandles(qx, githubHandles)
38+
39+
const foundHandles = new Set(allMemberRows.map((r) => r.githubHandle.toLowerCase()))
40+
const notFound = githubHandles.filter((h) => !foundHandles.has(h))
41+
42+
const pageMemberRows = allMemberRows.slice(offset, offset + pageSize)
43+
44+
if (pageMemberRows.length === 0) {
45+
ok(res, {
46+
total: githubHandles.length,
47+
totalFound: allMemberRows.length,
48+
page,
49+
pageSize,
50+
contributorsInPage: 0,
51+
contributors: [],
52+
notFound,
53+
})
54+
return
55+
}
56+
57+
const memberIds = pageMemberRows.map((r) => r.memberId)
58+
59+
// Step 2: fetch verified emails for current page
60+
const emailRows = await findVerifiedEmailsByMemberIds(qx, memberIds)
61+
62+
const emailsByMember = new Map<string, string[]>()
63+
for (const row of emailRows) {
64+
const list = emailsByMember.get(row.memberId) ?? []
65+
list.push(row.email)
66+
emailsByMember.set(row.memberId, list)
67+
}
68+
69+
// Step 3: resolve affiliations for current page only
70+
const affiliationsByMember = await resolveAffiliationsByMemberIds(qx, memberIds)
71+
72+
// Step 4: build response
73+
const contributors = pageMemberRows.map((member) => ({
74+
githubHandle: member.githubHandle,
75+
name: member.displayName,
76+
emails: emailsByMember.get(member.memberId) ?? [],
77+
affiliations: affiliationsByMember.get(member.memberId) ?? [],
78+
}))
79+
80+
ok(res, {
81+
total: githubHandles.length,
82+
totalFound: allMemberRows.length,
83+
page,
84+
pageSize,
85+
contributorsInPage: contributors.length,
86+
contributors,
87+
notFound,
88+
})
89+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Router } from 'express'
2+
3+
import { createRateLimiter } from '@/api/apiRateLimiter'
4+
import { requireScopes } from '@/api/public/middlewares/requireScopes'
5+
import { safeWrap } from '@/middlewares/errorMiddleware'
6+
import { SCOPES } from '@/security/scopes'
7+
8+
import { getAffiliationByHandle } from './getAffiliationByHandle'
9+
import { getAffiliations } from './getAffiliations'
10+
11+
const rateLimiter = createRateLimiter({ max: 60, windowMs: 60 * 1000 })
12+
13+
export function memberOrganizationAffiliationsRouter(): Router {
14+
const router = Router()
15+
16+
router.use(rateLimiter)
17+
18+
router.post('/', requireScopes([SCOPES.READ_AFFILIATIONS]), safeWrap(getAffiliations))
19+
router.get(
20+
'/:githubHandle',
21+
requireScopes([SCOPES.READ_AFFILIATIONS]),
22+
safeWrap(getAffiliationByHandle),
23+
)
24+
25+
return router
26+
}

backend/src/api/public/v1/dev-stats/index.ts

Lines changed: 0 additions & 19 deletions
This file was deleted.

backend/src/api/public/v1/index.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
11
import { Router } from 'express'
22

3+
import { NotFoundError } from '@crowd/common'
4+
5+
import { AUTH0_CONFIG } from '../../../conf'
6+
import { oauth2Middleware } from '../middlewares/oauth2Middleware'
7+
import { staticApiKeyMiddleware } from '../middlewares/staticApiKeyMiddleware'
8+
9+
import { memberOrganizationAffiliationsRouter } from './affiliations'
310
import { membersRouter } from './members'
411
import { organizationsRouter } from './organizations'
512

613
export function v1Router(): Router {
714
const router = Router()
815

9-
router.use('/members', membersRouter())
10-
router.use('/organizations', organizationsRouter())
16+
router.use('/members', oauth2Middleware(AUTH0_CONFIG), membersRouter())
17+
router.use('/organizations', oauth2Middleware(AUTH0_CONFIG), organizationsRouter())
18+
router.use('/affiliations', staticApiKeyMiddleware(), memberOrganizationAffiliationsRouter())
19+
20+
router.use(() => {
21+
throw new NotFoundError()
22+
})
1123

1224
return router
1325
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import type { Request, Response } from 'express'
2+
import { z } from 'zod'
3+
4+
import { captureApiChange, memberEditIdentitiesAction } from '@crowd/audit-logs'
5+
import { ConflictError, NotFoundError } from '@crowd/common'
6+
import {
7+
MemberField,
8+
checkMemberIdentityExistence,
9+
findMemberById,
10+
createMemberIdentity as insertMemberIdentity,
11+
optionsQx,
12+
touchMemberUpdatedAt,
13+
} from '@crowd/data-access-layer'
14+
import { IMemberIdentity, MemberIdentityType } from '@crowd/types'
15+
16+
import { created } from '@/utils/api'
17+
import { validateOrThrow } from '@/utils/validation'
18+
19+
const paramsSchema = z.object({
20+
memberId: z.uuid(),
21+
})
22+
23+
const bodySchema = z
24+
.object({
25+
value: z.string().min(1),
26+
platform: z.string().min(1),
27+
type: z.enum(MemberIdentityType),
28+
source: z.string().min(1),
29+
verified: z.boolean(),
30+
verifiedBy: z.string().optional(),
31+
})
32+
.refine((data) => !data.verified || data.verifiedBy, {
33+
message: 'verifiedBy is required when verified is true',
34+
path: ['verifiedBy'],
35+
})
36+
37+
export async function createMemberIdentity(req: Request, res: Response): Promise<void> {
38+
const { memberId } = validateOrThrow(paramsSchema, req.params)
39+
const data = validateOrThrow(bodySchema, req.body)
40+
41+
const qx = optionsQx(req)
42+
43+
const member = await findMemberById(qx, memberId, [MemberField.ID])
44+
if (!member) {
45+
throw new NotFoundError('Member not found')
46+
}
47+
48+
let result!: IMemberIdentity
49+
50+
await captureApiChange(
51+
req,
52+
memberEditIdentitiesAction(memberId, async (captureOldState, captureNewState) => {
53+
captureOldState({})
54+
55+
await qx.tx(async (tx) => {
56+
const existing = await checkMemberIdentityExistence(
57+
tx,
58+
data.value,
59+
data.platform,
60+
data.type,
61+
)
62+
63+
for (const identity of existing) {
64+
if (identity.memberId === memberId) {
65+
throw new ConflictError('Identity already exists on this member')
66+
}
67+
68+
if (identity.verified) {
69+
throw new ConflictError('Identity already verified on another member')
70+
}
71+
}
72+
73+
result = await insertMemberIdentity(
74+
tx,
75+
{
76+
memberId,
77+
platform: data.platform,
78+
value: data.value,
79+
type: data.type,
80+
source: data.source,
81+
verified: data.verified,
82+
verifiedBy: data.verifiedBy,
83+
},
84+
true,
85+
true,
86+
)
87+
88+
// touch member updated at to trigger merge suggestion
89+
await touchMemberUpdatedAt(tx, memberId)
90+
})
91+
92+
captureNewState(result)
93+
}),
94+
)
95+
96+
created(res, {
97+
id: result.id,
98+
value: result.value,
99+
platform: result.platform,
100+
verified: result.verified,
101+
verifiedBy: result.verifiedBy ?? null,
102+
source: result.source ?? null,
103+
createdAt: result.createdAt,
104+
updatedAt: result.updatedAt,
105+
})
106+
}

backend/src/api/public/v1/members/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { requireScopes } from '@/api/public/middlewares/requireScopes'
44
import { safeWrap } from '@/middlewares/errorMiddleware'
55
import { SCOPES } from '@/security/scopes'
66

7+
import { createMemberIdentity } from './identities/createMemberIdentity'
78
import { getMemberIdentities } from './identities/getMemberIdentities'
89
import { verifyMemberIdentity } from './identities/verifyMemberIdentity'
910
import { getMemberMaintainerRoles } from './maintainer-roles/getMemberMaintainerRoles'
@@ -27,6 +28,12 @@ export function membersRouter(): Router {
2728
safeWrap(getMemberIdentities),
2829
)
2930

31+
router.post(
32+
'/:memberId/identities',
33+
requireScopes([SCOPES.WRITE_MEMBER_IDENTITIES]),
34+
safeWrap(createMemberIdentity),
35+
)
36+
3037
router.patch(
3138
'/:memberId/identities/:identityId',
3239
requireScopes([SCOPES.WRITE_MEMBER_IDENTITIES]),

0 commit comments

Comments
 (0)