Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
ddb6611
feat: add static api key middleware for dev stats
ulemons Mar 19, 2026
0761019
fix: lint
ulemons Mar 19, 2026
9d265bf
fix: remove local secret
ulemons Mar 19, 2026
9bf2e94
fix: use db oriented api keys
ulemons Mar 19, 2026
ef9c57b
fix: remove useless env var
ulemons Mar 19, 2026
a32bb5f
fix: lint
ulemons Mar 19, 2026
33e5d2f
fix: review
ulemons Mar 20, 2026
fbc2a4b
feat: add query layer
ulemons Mar 19, 2026
e602856
feat: add filtering on query layer
ulemons Mar 19, 2026
8db2e26
feat: refactor in dal
ulemons Mar 19, 2026
3eca919
feat: add affiliations
ulemons Mar 20, 2026
00da7e9
feat: adding logs
ulemons Mar 20, 2026
2cb2483
fix: lint
ulemons Mar 20, 2026
cd368bc
fix: lint
ulemons Mar 20, 2026
ba92c27
fix: created at error
ulemons Mar 20, 2026
e16d5bf
fix: createdAt as date
ulemons Mar 20, 2026
75972d4
feat: adding logs
ulemons Mar 23, 2026
c753cca
refactor: simplify buildTimeline
ulemons Mar 23, 2026
0d36408
fix: lint
ulemons Mar 23, 2026
e88c8f1
fix: remove logs
ulemons Mar 23, 2026
e680cbe
fix: remove comments
ulemons Mar 23, 2026
788bfe6
fix: change logging
ulemons Mar 23, 2026
6f26621
refactor: create dal for affiliations
ulemons Mar 23, 2026
234672d
fix: lint
ulemons Mar 23, 2026
5845c67
refactor: export affiliation on dal
ulemons Mar 23, 2026
4c90f1b
refactor: simplify longestDateRange
ulemons Mar 23, 2026
74e3f2d
fix: filter first relevant orgs to avoid timeouts
ulemons Mar 24, 2026
ffe12ed
fix: align url
ulemons Mar 24, 2026
7145bea
fix: test not joining for member count
ulemons Mar 24, 2026
c25a4dd
fix: add safe wrap
ulemons Mar 24, 2026
6c1fd91
fix: refactor v1Router
ulemons Mar 25, 2026
1b5a0f2
fix: refactor naming
ulemons Mar 25, 2026
84fb096
fix: add 404 error
ulemons Mar 25, 2026
351a3c9
fix: add 404 error in public router
ulemons Mar 25, 2026
3428fb1
fix: page are query aprams
ulemons Mar 25, 2026
68b65fd
fix: revert not found inside the routher
ulemons Mar 25, 2026
ce35979
fix: adjust page fields
ulemons Mar 25, 2026
4afe7f0
fix: add contributors in page
ulemons Mar 25, 2026
9065657
fix: cursor review
ulemons Mar 25, 2026
cf15c87
fix: reduce log, simplify serializing error
ulemons Mar 25, 2026
bfacaad
fix: lint
ulemons Mar 25, 2026
b35061c
fix: body parser error interceptor
ulemons Mar 25, 2026
1f115c7
feat: adjust limits
ulemons Mar 25, 2026
b0d2507
feat: simplify query
ulemons Mar 25, 2026
d4cc31c
feat: use join instead of in
ulemons Mar 25, 2026
a47c1b3
feat: revert to memberOrganizations
ulemons Mar 25, 2026
7f70a2d
feat: cursor review
ulemons Mar 25, 2026
eaa776a
feat: remove useless logs
ulemons Mar 25, 2026
86da5ec
refactor: simplify affiliations
ulemons Mar 25, 2026
8886263
fix: early return
ulemons Mar 25, 2026
55cb440
fix: use common blacklist variable
ulemons Mar 26, 2026
f9ef818
feat: merge
ulemons Mar 26, 2026
8030024
fix: comment after review
ulemons Mar 27, 2026
850cfe2
fix: lint
ulemons Mar 27, 2026
06c0569
refactor: remove dev stats
ulemons Mar 27, 2026
226a9f0
refactor: rename interface
ulemons Mar 27, 2026
b20d003
refactor: update endpoint
ulemons Mar 27, 2026
ecbd125
refactor: lint
ulemons Mar 27, 2026
7377aae
fix: cursor comment
ulemons Mar 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as http from 'http'
import os from 'os'
import { QueryTypes } from 'sequelize'

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

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

app.use((err: any, req: any, res: any, next: any) => {
if (err.type === 'entity.parse.failed') {
next(new BadRequestError('Invalid JSON body'))
return
}
next(err)
})

app.use((req, res, next) => {
// @ts-ignore
req.userData = {
Expand Down
8 changes: 1 addition & 7 deletions backend/src/api/public/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
import { Router } from 'express'

import { AUTH0_CONFIG } from '../../conf'

import { errorHandler } from './middlewares/errorHandler'
import { oauth2Middleware } from './middlewares/oauth2Middleware'
import { staticApiKeyMiddleware } from './middlewares/staticApiKeyMiddleware'
import { v1Router } from './v1'
import { devStatsRouter } from './v1/dev-stats'

export function publicRouter(): Router {
const router = Router()

router.use('/v1/dev-stats', staticApiKeyMiddleware(), devStatsRouter())
router.use('/v1', oauth2Middleware(AUTH0_CONFIG), v1Router())
router.use('/v1', v1Router())
router.use(errorHandler)

return router
Expand Down
8 changes: 7 additions & 1 deletion backend/src/api/public/middlewares/errorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,13 @@ export const errorHandler: ErrorRequestHandler = (
}

req.log.error(
{ error, url: req.url, method: req.method, query: req.query, body: req.body },
{
error: { name: error?.name, message: error?.message, stack: error?.stack },
url: req.url,
method: req.method,
query: req.query,
body: req.body,
},
'Unhandled error in public API',
)

Expand Down
89 changes: 89 additions & 0 deletions backend/src/api/public/v1/affiliations/getAffiliations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { Request, Response } from 'express'
import { z } from 'zod'

import {
findMembersByGithubHandles,
findVerifiedEmailsByMemberIds,
optionsQx,
resolveAffiliationsByMemberIds,
} from '@crowd/data-access-layer'

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

const MAX_HANDLES = 100
const DEFAULT_PAGE_SIZE = 20

const bodySchema = z.object({
githubHandles: z
.array(z.string().trim().min(1).toLowerCase())
.min(1)
.max(MAX_HANDLES, `Maximum ${MAX_HANDLES} handles per request`),
})

const querySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(MAX_HANDLES).default(DEFAULT_PAGE_SIZE),
})

export async function getAffiliations(req: Request, res: Response): Promise<void> {
const { githubHandles } = validateOrThrow(bodySchema, req.body)
const { page, pageSize } = validateOrThrow(querySchema, req.query)
const qx = optionsQx(req)

const offset = (page - 1) * pageSize

// Step 1: find all verified members across all handles
const allMemberRows = await findMembersByGithubHandles(qx, githubHandles)

const foundHandles = new Set(allMemberRows.map((r) => r.githubHandle.toLowerCase()))
const notFound = githubHandles.filter((h) => !foundHandles.has(h))

const pageMemberRows = allMemberRows.slice(offset, offset + pageSize)

if (pageMemberRows.length === 0) {
ok(res, {
total: githubHandles.length,
totalFound: allMemberRows.length,
page,
pageSize,
contributorsInPage: 0,
contributors: [],
notFound,
})
return
}

const memberIds = pageMemberRows.map((r) => r.memberId)

// Step 2: fetch verified emails for current page
const emailRows = await findVerifiedEmailsByMemberIds(qx, memberIds)

const emailsByMember = new Map<string, string[]>()
for (const row of emailRows) {
const list = emailsByMember.get(row.memberId) ?? []
list.push(row.email)
emailsByMember.set(row.memberId, list)
}

// Step 3: resolve affiliations for current page only
const affiliationsByMember = await resolveAffiliationsByMemberIds(qx, memberIds)

// Step 4: build response
const contributors = pageMemberRows.map((member) => ({
githubHandle: member.githubHandle,
name: member.displayName,
emails: emailsByMember.get(member.memberId) ?? [],
affiliations: affiliationsByMember.get(member.memberId) ?? [],
}))

ok(res, {
total: githubHandles.length,
totalFound: allMemberRows.length,
page,
pageSize,
contributorsInPage: contributors.length,
contributors,
notFound,
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@ import { Router } from 'express'

import { createRateLimiter } from '@/api/apiRateLimiter'
import { requireScopes } from '@/api/public/middlewares/requireScopes'
import { safeWrap } from '@/middlewares/errorMiddleware'
import { SCOPES } from '@/security/scopes'

import { getAffiliations } from './getAffiliations'

const rateLimiter = createRateLimiter({ max: 60, windowMs: 60 * 1000 })

export function devStatsRouter(): Router {
export function memberOrganizationAffiliationsRouter(): Router {
const router = Router()

router.use(rateLimiter)

router.post('/affiliations', requireScopes([SCOPES.READ_AFFILIATIONS]), (_req, res) => {
res.json({ status: 'ok' })
})
router.post('/', requireScopes([SCOPES.READ_AFFILIATIONS]), safeWrap(getAffiliations))

return router
}
16 changes: 14 additions & 2 deletions backend/src/api/public/v1/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
import { Router } from 'express'

import { NotFoundError } from '@crowd/common'

import { AUTH0_CONFIG } from '../../../conf'
import { oauth2Middleware } from '../middlewares/oauth2Middleware'
import { staticApiKeyMiddleware } from '../middlewares/staticApiKeyMiddleware'

import { memberOrganizationAffiliationsRouter } from './affiliations'
import { membersRouter } from './members'
import { organizationsRouter } from './organizations'

export function v1Router(): Router {
const router = Router()

router.use('/members', membersRouter())
router.use('/organizations', organizationsRouter())
router.use('/members', oauth2Middleware(AUTH0_CONFIG), membersRouter())
router.use('/organizations', oauth2Middleware(AUTH0_CONFIG), organizationsRouter())
router.use('/affiliations', staticApiKeyMiddleware(), memberOrganizationAffiliationsRouter())

router.use(() => {
throw new NotFoundError()
})

return router
}
Loading
Loading