From 5fe3b630922e3275e352e113bdbd16bcd3a248fd Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:25:01 +0530 Subject: [PATCH] feat: add public API foundation and members v1 endpoints (CM-967) Co-authored-by: Cursor --- backend/package.json | 4 +- .../public/middlewares/oauth2Middleware.ts | 6 +-- .../api/public/middlewares/requireScopes.ts | 5 +- backend/src/api/public/v1/index.ts | 4 +- .../members/identities/getMemberIdentities.ts | 28 +++++++++++ .../identities/updateMemberIdentity.ts | 38 ++++++++++++++ backend/src/api/public/v1/members/index.ts | 43 ++++++++++++++++ .../getMemberMaintainerRoles.ts | 28 +++++++++++ .../api/public/v1/members/resolveMember.ts | 41 +++++++++++++++ .../getMemberWorkExperiences.ts | 28 +++++++++++ backend/src/security/scopes.ts | 10 ++-- backend/src/types/api.ts | 9 ---- backend/src/types/express.d.ts | 9 ++++ backend/src/utils/validation.ts | 17 +++++++ backend/tsconfig.json | 2 +- pnpm-lock.yaml | 11 ++++ services/libs/data-access-layer/src/index.ts | 1 + .../src/members/identities.ts | 50 +++++++++++++++++++ 18 files changed, 309 insertions(+), 25 deletions(-) create mode 100644 backend/src/api/public/v1/members/identities/getMemberIdentities.ts create mode 100644 backend/src/api/public/v1/members/identities/updateMemberIdentity.ts create mode 100644 backend/src/api/public/v1/members/index.ts create mode 100644 backend/src/api/public/v1/members/maintainer-roles/getMemberMaintainerRoles.ts create mode 100644 backend/src/api/public/v1/members/resolveMember.ts create mode 100644 backend/src/api/public/v1/members/work-experiences/getMemberWorkExperiences.ts create mode 100644 backend/src/types/express.d.ts create mode 100644 backend/src/utils/validation.ts diff --git a/backend/package.json b/backend/package.json index 50fe7277b1..03e4d363dc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -138,7 +138,8 @@ "uuid": "^9.0.0", "validator": "^13.7.0", "verify-github-webhook": "^1.0.1", - "zlib-sync": "^0.1.8" + "zlib-sync": "^0.1.8", + "zod": "^4.3.6" }, "private": true, "devDependencies": { @@ -150,6 +151,7 @@ "@types/bunyan-format": "^0.2.5", "@types/config": "^3.3.0", "@types/cron": "^2.0.0", + "@types/express": "^4.17.17", "@types/html-to-text": "^8.1.1", "@types/node": "~18.0.4", "@types/sanitize-html": "^2.6.2", diff --git a/backend/src/api/public/middlewares/oauth2Middleware.ts b/backend/src/api/public/middlewares/oauth2Middleware.ts index a538b27729..1a368e0ece 100644 --- a/backend/src/api/public/middlewares/oauth2Middleware.ts +++ b/backend/src/api/public/middlewares/oauth2Middleware.ts @@ -4,7 +4,7 @@ import { auth } from 'express-oauth2-jwt-bearer' import { UnauthorizedError } from '@crowd/common' import type { Auth0Configuration } from '@/conf/configTypes' -import type { ApiRequest, Auth0TokenPayload } from '@/types/api' +import type { Auth0TokenPayload } from '@/types/api' function resolveActor(req: Request, _res: Response, next: NextFunction): void { const payload = (req.auth?.payload ?? {}) as Auth0TokenPayload @@ -18,11 +18,9 @@ function resolveActor(req: Request, _res: Response, next: NextFunction): void { const id = rawId.replace(/@clients$/, '') - const authReq = req as ApiRequest - const scopes = typeof payload.scope === 'string' ? payload.scope.split(' ').filter(Boolean) : [] - authReq.actor = { id, type: 'service', scopes } + req.actor = { id, type: 'service', scopes } next() } diff --git a/backend/src/api/public/middlewares/requireScopes.ts b/backend/src/api/public/middlewares/requireScopes.ts index 4b508e0acb..31bad381c2 100644 --- a/backend/src/api/public/middlewares/requireScopes.ts +++ b/backend/src/api/public/middlewares/requireScopes.ts @@ -1,13 +1,12 @@ -import type { NextFunction, Response } from 'express' +import type { NextFunction, Request, Response } from 'express' import { InsufficientScopeError, UnauthorizedError } from '@crowd/common' import { Scope } from '@/security/scopes' -import type { ApiRequest } from '@/types/api' export const requireScopes = (required: Scope[], mode: 'all' | 'any' = 'all') => - (req: ApiRequest, _res: Response, next: NextFunction) => { + (req: Request, _res: Response, next: NextFunction) => { if (!req.actor) { next(new UnauthorizedError()) return diff --git a/backend/src/api/public/v1/index.ts b/backend/src/api/public/v1/index.ts index 23e70fa834..79104e77ed 100644 --- a/backend/src/api/public/v1/index.ts +++ b/backend/src/api/public/v1/index.ts @@ -1,11 +1,11 @@ import { Router } from 'express' -import { identitiesRouter } from './identities' +import { membersRouter } from './members' export function v1Router(): Router { const router = Router() - router.use('/identities', identitiesRouter()) + router.use('/members', membersRouter()) return router } diff --git a/backend/src/api/public/v1/members/identities/getMemberIdentities.ts b/backend/src/api/public/v1/members/identities/getMemberIdentities.ts new file mode 100644 index 0000000000..6826ffa584 --- /dev/null +++ b/backend/src/api/public/v1/members/identities/getMemberIdentities.ts @@ -0,0 +1,28 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' + +import { NotFoundError } from '@crowd/common' +import { fetchMemberIdentities, findMemberById, optionsQx } from '@crowd/data-access-layer' +import { MemberField } from '@crowd/data-access-layer/src/members/base' + +import { ok } from '@/utils/api' +import { validateOrThrow } from '@/utils/validation' + +const paramsSchema = z.object({ + memberId: z.uuid(), +}) + +export async function getMemberIdentities(req: Request, res: Response): Promise { + const { memberId } = validateOrThrow(paramsSchema, req.params) + const qx = optionsQx(req) + + const member = await findMemberById(qx, memberId, [MemberField.ID]) + + if (!member) { + throw new NotFoundError('Member profile not found') + } + + const identities = await fetchMemberIdentities(qx, memberId) + + ok(res, { identities }) +} diff --git a/backend/src/api/public/v1/members/identities/updateMemberIdentity.ts b/backend/src/api/public/v1/members/identities/updateMemberIdentity.ts new file mode 100644 index 0000000000..5cf1b2d4d0 --- /dev/null +++ b/backend/src/api/public/v1/members/identities/updateMemberIdentity.ts @@ -0,0 +1,38 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' + +import { NotFoundError } from '@crowd/common' +import { findMemberById, optionsQx } from '@crowd/data-access-layer' +import { MemberField } from '@crowd/data-access-layer/src/members/base' + +import { ok } from '@/utils/api' +import { validateOrThrow } from '@/utils/validation' + +const paramsSchema = z.object({ + memberId: z.uuid(), +}) + +// const bodySchema = z.object({ +// verified: z.boolean(), +// verifiedBy: z.string().optional(), +// }) + +export async function updateMemberIdentity(req: Request, res: Response): Promise { + const qx = optionsQx(req) + + const { memberId } = validateOrThrow(paramsSchema, req.params) + + const member = await findMemberById(qx, memberId, [MemberField.ID]) + + if (!member) { + throw new NotFoundError('Member profile not found') + } + + // const { verified, verifiedBy } = validateOrThrow(bodySchema, req.body) + + // if verified is true, update the verified flag for the identity + verifiedBy source + // if verified is false, then chcek if the identity has any activity, if it doesn't then soft delete + // if it has activity, then unmerge the identity from the member + + ok(res, { success: true }) +} diff --git a/backend/src/api/public/v1/members/index.ts b/backend/src/api/public/v1/members/index.ts new file mode 100644 index 0000000000..a23d5e5000 --- /dev/null +++ b/backend/src/api/public/v1/members/index.ts @@ -0,0 +1,43 @@ +import { Router } from 'express' + +import { requireScopes } from '@/api/public/middlewares/requireScopes' +import { safeWrap } from '@/middlewares/errorMiddleware' +import { SCOPES } from '@/security/scopes' + +import { getMemberIdentities } from './identities/getMemberIdentities' +import { updateMemberIdentity } from './identities/updateMemberIdentity' +import { getMemberMaintainerRoles } from './maintainer-roles/getMemberMaintainerRoles' +import { resolveMemberByIdentities } from './resolveMember' +import { getMemberWorkExperiences } from './work-experiences/getMemberWorkExperiences' + +export function membersRouter(): Router { + const router = Router() + + router.post('/resolve', requireScopes([SCOPES.READ_MEMBERS]), safeWrap(resolveMemberByIdentities)) + + router.get( + '/:memberId/identities', + requireScopes([SCOPES.READ_MEMBER_IDENTITIES]), + safeWrap(getMemberIdentities), + ) + + router.patch( + '/:memberId/identities/:identityId', + requireScopes([SCOPES.WRITE_MEMBER_IDENTITIES]), + safeWrap(updateMemberIdentity), + ) + + router.get( + '/:memberId/maintainer-roles', + requireScopes([SCOPES.READ_MAINTAINER_ROLES]), + safeWrap(getMemberMaintainerRoles), + ) + + router.get( + '/:memberId/work-experiences', + requireScopes([SCOPES.READ_WORK_EXPERIENCES]), + safeWrap(getMemberWorkExperiences), + ) + + return router +} diff --git a/backend/src/api/public/v1/members/maintainer-roles/getMemberMaintainerRoles.ts b/backend/src/api/public/v1/members/maintainer-roles/getMemberMaintainerRoles.ts new file mode 100644 index 0000000000..808d80ee24 --- /dev/null +++ b/backend/src/api/public/v1/members/maintainer-roles/getMemberMaintainerRoles.ts @@ -0,0 +1,28 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' + +import { NotFoundError } from '@crowd/common' +import { findMaintainerRoles, findMemberById, optionsQx } from '@crowd/data-access-layer' +import { MemberField } from '@crowd/data-access-layer/src/members/base' + +import { ok } from '@/utils/api' +import { validateOrThrow } from '@/utils/validation' + +const paramsSchema = z.object({ + memberId: z.uuid(), +}) + +export async function getMemberMaintainerRoles(req: Request, res: Response): Promise { + const { memberId } = validateOrThrow(paramsSchema, req.params) + const qx = optionsQx(req) + + const member = await findMemberById(qx, memberId, [MemberField.ID]) + + if (!member) { + throw new NotFoundError('Member profile not found') + } + + const maintainerRoles = await findMaintainerRoles(qx, [memberId]) + + ok(res, { maintainerRoles }) +} diff --git a/backend/src/api/public/v1/members/resolveMember.ts b/backend/src/api/public/v1/members/resolveMember.ts new file mode 100644 index 0000000000..7864785e46 --- /dev/null +++ b/backend/src/api/public/v1/members/resolveMember.ts @@ -0,0 +1,41 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' + +import { ConflictError, NotFoundError } from '@crowd/common' +import { findMemberIdsByIdentities, optionsQx } from '@crowd/data-access-layer' +import { IMemberIdentity, MemberIdentityType, PlatformType } from '@crowd/types' + +import { ok } from '@/utils/api' +import { validateOrThrow } from '@/utils/validation' + +const bodySchema = z.object({ + lfids: z.array(z.string().trim().min(1)), + emails: z.array(z.email()).optional(), +}) + +export async function resolveMemberByIdentities(req: Request, res: Response): Promise { + const { lfids, emails } = validateOrThrow(bodySchema, req.body) + + const qx = optionsQx(req) + + const identities: Partial[] = [ + ...lfids.map((lfid) => ({ + platform: PlatformType.LFID, + type: MemberIdentityType.USERNAME, + value: lfid, + })), + ...(emails?.map((email) => ({ type: MemberIdentityType.EMAIL, value: email })) ?? []), + ] + + const memberIds = await findMemberIdsByIdentities(qx, identities) + + if (memberIds.length === 0) { + throw new NotFoundError('Member profile not found!') + } else if (memberIds.length > 1) { + throw new ConflictError('Conflicting identities!') + } + + const memberId = memberIds[0] + + ok(res, { memberId }) +} diff --git a/backend/src/api/public/v1/members/work-experiences/getMemberWorkExperiences.ts b/backend/src/api/public/v1/members/work-experiences/getMemberWorkExperiences.ts new file mode 100644 index 0000000000..bcbeac73ec --- /dev/null +++ b/backend/src/api/public/v1/members/work-experiences/getMemberWorkExperiences.ts @@ -0,0 +1,28 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' + +import { NotFoundError } from '@crowd/common' +import { fetchMemberOrganizations, findMemberById, optionsQx } from '@crowd/data-access-layer' +import { MemberField } from '@crowd/data-access-layer/src/members/base' + +import { ok } from '@/utils/api' +import { validateOrThrow } from '@/utils/validation' + +const paramsSchema = z.object({ + memberId: z.uuid(), +}) + +export async function getMemberWorkExperiences(req: Request, res: Response): Promise { + const { memberId } = validateOrThrow(paramsSchema, req.params) + const qx = optionsQx(req) + + const member = await findMemberById(qx, memberId, [MemberField.ID]) + + if (!member) { + throw new NotFoundError('Member profile not found') + } + + const workExperiences = await fetchMemberOrganizations(qx, memberId) + + ok(res, { workExperiences }) +} diff --git a/backend/src/security/scopes.ts b/backend/src/security/scopes.ts index dd5b16e704..d6a0ec9504 100644 --- a/backend/src/security/scopes.ts +++ b/backend/src/security/scopes.ts @@ -1,12 +1,12 @@ export const SCOPES = { READ_MEMBERS: 'read:members', - READ_IDENTITIES: 'read:identities', - WRITE_IDENTITIES: 'write:identities', - READ_ROLES: 'read:roles', + READ_MEMBER_IDENTITIES: 'read:member-identities', + WRITE_MEMBER_IDENTITIES: 'write:member-identities', + READ_MAINTAINER_ROLES: 'read:maintainer-roles', READ_WORK_EXPERIENCES: 'read:work-experiences', WRITE_WORK_EXPERIENCES: 'write:work-experiences', - READ_PROJECTS_AFFILIATIONS: 'read:projects-affiliations', - WRITE_PROJECTS_AFFILIATIONS: 'write:projects-affiliations', + READ_PROJECT_AFFILIATIONS: 'read:project-affiliations', + WRITE_PROJECT_AFFILIATIONS: 'write:project-affiliations', } as const export type Scope = (typeof SCOPES)[keyof typeof SCOPES] diff --git a/backend/src/types/api.ts b/backend/src/types/api.ts index 1214f9798c..7d03c540c0 100644 --- a/backend/src/types/api.ts +++ b/backend/src/types/api.ts @@ -1,4 +1,3 @@ -import type { Request } from 'express' import type { JWTPayload } from 'express-oauth2-jwt-bearer' /** @@ -19,11 +18,3 @@ export interface Actor { id: string scopes: string[] } - -/** - * Express request with authenticated actor - * Use req.actor to check identity and permissions - */ -export interface ApiRequest extends Request { - actor: Actor -} diff --git a/backend/src/types/express.d.ts b/backend/src/types/express.d.ts new file mode 100644 index 0000000000..edd8835098 --- /dev/null +++ b/backend/src/types/express.d.ts @@ -0,0 +1,9 @@ +import type { Actor } from '@/types/api' + +declare global { + namespace Express { + interface Request { + actor: Actor + } + } +} diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts new file mode 100644 index 0000000000..bc58a09daa --- /dev/null +++ b/backend/src/utils/validation.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' + +import { BadRequestError } from '@crowd/common' + +export function validateOrThrow(schema: T, data: unknown): z.infer { + const result = schema.safeParse(data) + + if (!result.success) { + const messages = result.error.issues.map((issue) => { + const path = issue.path.length ? `${issue.path.join('.')}: ` : '' + return `${path}${issue.message}` + }) + throw new BadRequestError(messages.join('; ')) + } + + return result.data +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json index f3405cdcaf..d4a6bcb467 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -10,7 +10,7 @@ "noUnusedParameters": false, "sourceMap": true, "target": "es2018", - "types": ["node"], + "types": ["node", "express"], "baseUrl": "./src", "paths": { "@/*": ["./*"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40b888b73b..60023f63e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -351,6 +351,9 @@ importers: zlib-sync: specifier: ^0.1.8 version: 0.1.9 + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@babel/core': specifier: ^7.24.4 @@ -376,6 +379,9 @@ importers: '@types/cron': specifier: ^2.0.0 version: 2.4.0 + '@types/express': + specifier: ^4.17.17 + version: 4.17.21 '@types/html-to-text': specifier: ^8.1.1 version: 8.1.1 @@ -10196,6 +10202,9 @@ packages: zlib-sync@0.1.9: resolution: {integrity: sha512-DinB43xCjVwIBDpaIvQqHbmDsnYnSt6HJ/yiB2MZQGTqgPcwBSZqLkimXwK8BvdjQ/MaZysb5uEenImncqvCqQ==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + snapshots: '@actions/core@1.10.1': @@ -20541,3 +20550,5 @@ snapshots: zlib-sync@0.1.9: dependencies: nan: 2.19.0 + + zod@4.3.6: {} diff --git a/services/libs/data-access-layer/src/index.ts b/services/libs/data-access-layer/src/index.ts index 0a576d8064..d95422ca1f 100644 --- a/services/libs/data-access-layer/src/index.ts +++ b/services/libs/data-access-layer/src/index.ts @@ -10,3 +10,4 @@ export * from './security_insights' export * from './systemSettings' export * from './integrations' export * from './auditLogs' +export * from './maintainers' diff --git a/services/libs/data-access-layer/src/members/identities.ts b/services/libs/data-access-layer/src/members/identities.ts index fc7496d81d..4b432c68c3 100644 --- a/services/libs/data-access-layer/src/members/identities.ts +++ b/services/libs/data-access-layer/src/members/identities.ts @@ -570,3 +570,53 @@ export async function findIdentitiesForMembers( return resultMap } + +/** + * Retrieve member IDs matching any of the given identities. + */ +export async function findMemberIdsByIdentities( + qx: QueryExecutor, + identities: Partial[], +): Promise { + if (!identities.length) return [] + + const conditions: string[] = [] + const params: Record = {} + + identities.forEach((identity, i) => { + const parts: string[] = [] + + Object.entries(identity).forEach(([key, value]) => { + if (value == null) return + + const paramName = `${key}_${i}` + + // Special handling: lowercase 'value' for case-insensitive match + if (key === 'value') { + parts.push(`lower(mi.${key}) = $(${paramName})`) + params[paramName] = (value as string).toLowerCase() + } else { + parts.push(`mi.${key} = $(${paramName})`) + params[paramName] = value as string + } + }) + + if (parts.length > 0) { + conditions.push(`(${parts.join(' AND ')})`) + } + }) + + if (!conditions.length) return [] + + const result = await qx.select( + ` + SELECT DISTINCT mi."memberId" + FROM "memberIdentities" mi + WHERE mi."deletedAt" IS NULL + AND (${conditions.join(' OR ')}) + `, + params, + ) + + return result.map((r) => r.memberId) +}