From 7ea9d24e53c15f9bb3141a0a2c879855f886faae Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:17:26 +0530 Subject: [PATCH] feat: enrich identity conflict responses with member ids (CM-1349) Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com> --- .../api/public/middlewares/errorHandler.ts | 10 +---- .../src/api/public/v1/members/createMember.ts | 18 ++++++-- .../identities/createMemberIdentity.ts | 27 +++++++++--- .../identities/verifyMemberIdentity.ts | 13 +++++- backend/src/utils/err.ts | 4 ++ services/libs/common/src/errors/http.ts | 43 +++++++++++-------- 6 files changed, 77 insertions(+), 38 deletions(-) diff --git a/backend/src/api/public/middlewares/errorHandler.ts b/backend/src/api/public/middlewares/errorHandler.ts index 4a5ce182f3..1c8ff20ffa 100644 --- a/backend/src/api/public/middlewares/errorHandler.ts +++ b/backend/src/api/public/middlewares/errorHandler.ts @@ -4,13 +4,7 @@ import { UnauthorizedError as Auth0UnauthorizedError, } from 'express-oauth2-jwt-bearer' -import { - ConflictError, - HttpError, - InsufficientScopeError, - InternalError, - UnauthorizedError, -} from '@crowd/common' +import { HttpError, InsufficientScopeError, InternalError, UnauthorizedError } from '@crowd/common' import { alertOnce } from '@/api/public/alerts/alertOnce' @@ -30,7 +24,7 @@ export const errorHandler: ErrorRequestHandler = ( code: error.code, message: error.message, name: error.name, - context: error instanceof ConflictError ? error.context : undefined, + context: error.context, stack: error.status >= 500 ? error.stack : undefined, }) res.status(error.status).json(error.toJSON()) diff --git a/backend/src/api/public/v1/members/createMember.ts b/backend/src/api/public/v1/members/createMember.ts index d11d4671ba..1551c0eb31 100644 --- a/backend/src/api/public/v1/members/createMember.ts +++ b/backend/src/api/public/v1/members/createMember.ts @@ -3,12 +3,16 @@ import { z } from 'zod' import { captureApiChange, memberCreateAction, memberEditIdentitiesAction } from '@crowd/audit-logs' import { getProperDisplayName } from '@crowd/common' -import { createMember as insertMember, insertMemberIdentities } from '@crowd/data-access-layer' +import { + findMemberIdByVerifiedIdentity, + createMember as insertMember, + insertMemberIdentities, +} from '@crowd/data-access-layer' import { MemberIdentityType } from '@crowd/types' import { optionsQx } from '@/database/sequelizeQueryExecutor' import { created } from '@/utils/api' -import { rethrowDbConflict } from '@/utils/err' +import { isMemberIdentityDbConflict, rethrowDbConflict } from '@/utils/err' import { validateOrThrow } from '@/utils/validation' const bodySchema = z.object({ @@ -62,9 +66,17 @@ export async function createMember(req: Request, res: Response): Promise { return { dbMember, dbIdentities } } catch (error) { // Only notify for a single identity because we can't tell which one conflicted in a batch. - if (identities.length === 1) { + if (identities.length === 1 && isMemberIdentityDbConflict(error)) { const identity = identities[0] + const conflictMemberId = await findMemberIdByVerifiedIdentity( + qx, + identity.platform, + identity.value, + identity.type, + ) + return rethrowDbConflict(error, { + ...(conflictMemberId ? { conflictMemberId } : {}), platform: identity.platform, value: identity.value, type: identity.type, diff --git a/backend/src/api/public/v1/members/identities/createMemberIdentity.ts b/backend/src/api/public/v1/members/identities/createMemberIdentity.ts index 029605ee86..f47dc696e7 100644 --- a/backend/src/api/public/v1/members/identities/createMemberIdentity.ts +++ b/backend/src/api/public/v1/members/identities/createMemberIdentity.ts @@ -6,6 +6,7 @@ import { NotFoundError } from '@crowd/common' import { MemberField, findMemberById, + findMemberIdByVerifiedIdentity, findMemberIdentitiesByValue, insertMemberIdentities, touchMemberUpdatedAt, @@ -15,7 +16,7 @@ import { IMemberIdentity, MemberIdentityType } from '@crowd/types' import { optionsQx } from '@/database/sequelizeQueryExecutor' import { created, ok } from '@/utils/api' -import { rethrowDbConflict } from '@/utils/err' +import { isMemberIdentityDbConflict, rethrowDbConflict } from '@/utils/err' import { validateOrThrow } from '@/utils/validation' const paramsSchema = z.object({ @@ -101,12 +102,24 @@ export async function createMemberIdentity(req: Request, res: Response): Promise } } } catch (error) { - rethrowDbConflict(error, { - memberId, - platform: data.platform, - value: data.value, - type: data.type, - }) + if (isMemberIdentityDbConflict(error)) { + const conflictMemberId = await findMemberIdByVerifiedIdentity( + qx, + data.platform, + data.value, + data.type, + ) + + rethrowDbConflict(error, { + memberId, + ...(conflictMemberId ? { conflictMemberId } : {}), + platform: data.platform, + value: data.value, + type: data.type, + }) + } + + throw error } await touchMemberUpdatedAt(tx, memberId) diff --git a/backend/src/api/public/v1/members/identities/verifyMemberIdentity.ts b/backend/src/api/public/v1/members/identities/verifyMemberIdentity.ts index f62ceea289..7f7fed4e7f 100644 --- a/backend/src/api/public/v1/members/identities/verifyMemberIdentity.ts +++ b/backend/src/api/public/v1/members/identities/verifyMemberIdentity.ts @@ -17,6 +17,7 @@ import { MemberField, deleteMemberIdentity, findMemberById, + findMemberIdByVerifiedIdentity, findMemberIdentityById, queryActivityRelations, updateMemberIdentity, @@ -30,7 +31,7 @@ import { import { optionsQx } from '@/database/sequelizeQueryExecutor' import { noContent, ok } from '@/utils/api' -import { rethrowDbConflict } from '@/utils/err' +import { isMemberIdentityDbConflict, rethrowDbConflict } from '@/utils/err' import { validateOrThrow } from '@/utils/validation' const paramsSchema = z.object({ @@ -92,9 +93,17 @@ export async function verifyMemberIdentity(req: Request, res: Response): Promise verifiedBy, }) } catch (error) { - if (verified) { + if (verified && isMemberIdentityDbConflict(error)) { + const conflictMemberId = await findMemberIdByVerifiedIdentity( + qx, + identity.platform, + identity.value, + identity.type, + ) + rethrowDbConflict(error, { memberId, + ...(conflictMemberId ? { conflictMemberId } : {}), platform: identity.platform, value: identity.value, type: identity.type, diff --git a/backend/src/utils/err.ts b/backend/src/utils/err.ts index b2733af920..6fc3f57c9e 100644 --- a/backend/src/utils/err.ts +++ b/backend/src/utils/err.ts @@ -9,6 +9,10 @@ const DB_CONFLICT_MAP: Record = { new ConflictError('Identity already exists on another member', context), } +export function isMemberIdentityDbConflict(error: unknown): boolean { + return (getDbConstraint(error) ?? '') in DB_CONFLICT_MAP +} + export function rethrowDbConflict(error: unknown, context?: Record): never { const factory = DB_CONFLICT_MAP[getDbConstraint(error) ?? ''] diff --git a/services/libs/common/src/errors/http.ts b/services/libs/common/src/errors/http.ts index 2a3f51e29f..dd29489af1 100644 --- a/services/libs/common/src/errors/http.ts +++ b/services/libs/common/src/errors/http.ts @@ -5,10 +5,12 @@ export abstract class HttpError extends Error { abstract readonly code: string abstract readonly status: number + readonly context?: Record - constructor(message: string) { + constructor(message: string, context?: Record) { super(message) this.name = this.constructor.name + this.context = context Object.setPrototypeOf(this, new.target.prototype) } @@ -17,6 +19,7 @@ export abstract class HttpError extends Error { error: { code: this.code, message: this.message, + ...(this.context ? { context: this.context } : {}), }, } } @@ -26,8 +29,8 @@ export class BadRequestError extends HttpError { readonly code = 'BAD_REQUEST' readonly status = 400 - constructor(message = 'Bad request') { - super(message) + constructor(message = 'Bad request', context?: Record) { + super(message, context) } } @@ -35,8 +38,8 @@ export class UnauthorizedError extends HttpError { readonly code = 'UNAUTHORIZED' readonly status = 401 - constructor(message = 'Unauthorized') { - super(message) + constructor(message = 'Unauthorized', context?: Record) { + super(message, context) } } @@ -44,8 +47,8 @@ export class ForbiddenError extends HttpError { readonly code = 'FORBIDDEN' readonly status = 403 - constructor(message = 'Forbidden') { - super(message) + constructor(message = 'Forbidden', context?: Record) { + super(message, context) } } @@ -53,8 +56,11 @@ export class InsufficientScopeError extends HttpError { readonly code = 'INSUFFICIENT_SCOPE' readonly status = 403 - constructor(message = 'Insufficient scope for this operation') { - super(message) + constructor( + message = 'Insufficient scope for this operation', + context?: Record, + ) { + super(message, context) } } @@ -62,19 +68,17 @@ export class NotFoundError extends HttpError { readonly code = 'NOT_FOUND' readonly status = 404 - constructor(message = 'Not found') { - super(message) + constructor(message = 'Not found', context?: Record) { + super(message, context) } } export class ConflictError extends HttpError { readonly code = 'CONFLICT' readonly status = 409 - readonly context?: Record constructor(message = 'Conflict', context?: Record) { - super(message) - this.context = context + super(message, context) } } @@ -82,8 +86,11 @@ export class RateLimitError extends HttpError { readonly code = 'RATE_LIMITED' readonly status = 429 - constructor(message = 'Too many requests, please try again later') { - super(message) + constructor( + message = 'Too many requests, please try again later', + context?: Record, + ) { + super(message, context) } } @@ -91,7 +98,7 @@ export class InternalError extends HttpError { readonly code = 'INTERNAL_ERROR' readonly status = 500 - constructor(message = 'Internal server error') { - super(message) + constructor(message = 'Internal server error', context?: Record) { + super(message, context) } }