Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
10153ab
feat: add static api key middleware for dev stats
ulemons Mar 19, 2026
6a98f5a
fix: lint
ulemons Mar 19, 2026
1294fa2
fix: remove local secret
ulemons Mar 19, 2026
762cf69
fix: use db oriented api keys
ulemons Mar 19, 2026
3665933
fix: remove useless env var
ulemons Mar 19, 2026
d48726c
fix: lint
ulemons Mar 19, 2026
2d6d04d
fix: review
ulemons Mar 20, 2026
f46528c
feat: add query layer
ulemons Mar 19, 2026
c5aab4d
feat: add filtering on query layer
ulemons Mar 19, 2026
77c19e4
feat: refactor in dal
ulemons Mar 19, 2026
eb9bd90
feat: add affiliations
ulemons Mar 20, 2026
4aaaeda
feat: adding logs
ulemons Mar 20, 2026
2d1efe2
fix: lint
ulemons Mar 20, 2026
210d19a
fix: lint
ulemons Mar 20, 2026
b3a20a1
fix: created at error
ulemons Mar 20, 2026
4b136e1
fix: createdAt as date
ulemons Mar 20, 2026
c5ea933
feat: adding logs
ulemons Mar 23, 2026
9210724
refactor: simplify buildTimeline
ulemons Mar 23, 2026
81812f0
fix: lint
ulemons Mar 23, 2026
a6bbb4c
fix: remove logs
ulemons Mar 23, 2026
6eeebdd
fix: remove comments
ulemons Mar 23, 2026
31f2b12
fix: change logging
ulemons Mar 23, 2026
6cb439b
refactor: create dal for affiliations
ulemons Mar 23, 2026
f2717aa
fix: lint
ulemons Mar 23, 2026
5e1cd2f
refactor: export affiliation on dal
ulemons Mar 23, 2026
af93e96
refactor: simplify longestDateRange
ulemons Mar 23, 2026
69e1e18
fix: filter first relevant orgs to avoid timeouts
ulemons Mar 24, 2026
d4d96c0
fix: align url
ulemons Mar 24, 2026
9dfa8eb
fix: test not joining for member count
ulemons Mar 24, 2026
eb89408
fix: add safe wrap
ulemons Mar 24, 2026
4d2c356
fix: refactor v1Router
ulemons Mar 25, 2026
38e0f29
fix: refactor naming
ulemons Mar 25, 2026
d52fa95
fix: add 404 error
ulemons Mar 25, 2026
23aee07
fix: add 404 error in public router
ulemons Mar 25, 2026
d309844
fix: page are query aprams
ulemons Mar 25, 2026
24888d3
fix: revert not found inside the routher
ulemons Mar 25, 2026
ef6d326
fix: adjust page fields
ulemons Mar 25, 2026
5c3246a
fix: add contributors in page
ulemons Mar 25, 2026
750041c
fix: cursor review
ulemons Mar 25, 2026
2e5a4f4
fix: reduce log, simplify serializing error
ulemons Mar 25, 2026
93824fe
fix: lint
ulemons Mar 25, 2026
9479f9e
fix: body parser error interceptor
ulemons Mar 25, 2026
a37c39d
feat: adjust limits
ulemons Mar 25, 2026
88060a1
feat: simplify query
ulemons Mar 25, 2026
df54389
feat: use join instead of in
ulemons Mar 25, 2026
1171def
feat: revert to memberOrganizations
ulemons Mar 25, 2026
e9cca17
feat: cursor review
ulemons Mar 25, 2026
0dd1e88
feat: remove useless logs
ulemons Mar 25, 2026
a36abae
refactor: simplify affiliations
ulemons Mar 25, 2026
543c953
fix: early return
ulemons Mar 25, 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
8 changes: 8 additions & 0 deletions backend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,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') {
res.status(400).json({ error: { code: 'BAD_REQUEST', message: '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/dev-stats/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,
})
}
7 changes: 4 additions & 3 deletions backend/src/api/public/v1/dev-stats/index.ts
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 {
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 { devStatsRouter } from './dev-stats'
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('/member-organization-affiliations', staticApiKeyMiddleware(), devStatsRouter())

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

return router
}
Loading
Loading