From 92e0dc0248636a46e3085223b93866523854c742 Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:17:27 +0530 Subject: [PATCH 1/2] chore: dedupe public API conflict alerts (CM-1349) Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com> --- backend/.eslintrc.js | 2 +- .../src/api/public/alerts/identityConflict.ts | 60 +++++++++++++++++++ .../public/alerts/memberResolveConflict.ts | 28 +++++++++ backend/src/api/public/alerts/notifyOnce.ts | 39 ++++++++++++ .../api/public/middlewares/errorHandler.ts | 32 +--------- .../src/api/public/v1/members/createMember.ts | 14 +++++ .../identities/createMemberIdentity.ts | 10 +++- .../identities/verifyMemberIdentity.ts | 10 +++- .../api/public/v1/members/resolveMember.ts | 5 +- 9 files changed, 160 insertions(+), 40 deletions(-) create mode 100644 backend/src/api/public/alerts/identityConflict.ts create mode 100644 backend/src/api/public/alerts/memberResolveConflict.ts create mode 100644 backend/src/api/public/alerts/notifyOnce.ts diff --git a/backend/.eslintrc.js b/backend/.eslintrc.js index 1b3253a7b7..6e0768d2af 100644 --- a/backend/.eslintrc.js +++ b/backend/.eslintrc.js @@ -47,6 +47,7 @@ module.exports = { }, rules: { semi: ['error', 'never'], + 'no-void': 'off', 'prefer-destructuring': ['error', { object: false, array: false }], 'no-param-reassign': 0, 'no-underscore-dangle': 0, @@ -82,7 +83,6 @@ module.exports = { json: 'always', }, ], - }, }, ], } diff --git a/backend/src/api/public/alerts/identityConflict.ts b/backend/src/api/public/alerts/identityConflict.ts new file mode 100644 index 0000000000..4bfdb7dc3b --- /dev/null +++ b/backend/src/api/public/alerts/identityConflict.ts @@ -0,0 +1,60 @@ +import type { Request } from 'express' + +import { ConflictError } from '@crowd/common' +import type { IMemberIdentity } from '@crowd/types' + +import { notifyOnce } from '@/api/public/alerts/notifyOnce' +import { rethrowDbConflict } from '@/utils/err' + +type IdentityConflictSubject = Pick + +function notifyIdentityConflict( + req: Request, + identity: IdentityConflictSubject, + message: string, +): void { + const dedupeKey = [ + 'member-identity-conflict', + identity.platform, + identity.type, + identity.value, + identity.memberId, + ] + .filter(Boolean) + .join(':') + + void notifyOnce(req, dedupeKey, 'Identity conflict', [ + { + title: 'Identity', + text: `*Platform:* \`${identity.platform}\`\n*Type:* \`${identity.type}\`\n*Value:* \`${identity.value}\``, + }, + ...(identity.memberId + ? [{ title: 'Member', text: `*Member ID:* \`${identity.memberId}\`` }] + : []), + { title: 'Conflict', text: `*Message:* ${message}` }, + { + title: 'Request', + text: `*Method:* \`${req.method}\`\n*URL:* \`${req.url}\``, + }, + ]) +} + +/** Maps identity unique violations to ConflictError and alerts once. */ +export function rethrowIdentityConflict( + req: Request, + error: unknown, + identity: IdentityConflictSubject, +): never { + try { + rethrowDbConflict(error, { + platform: identity.platform, + value: identity.value, + type: identity.type, + }) + } catch (e) { + if (e instanceof ConflictError) { + notifyIdentityConflict(req, identity, e.message) + } + throw e + } +} diff --git a/backend/src/api/public/alerts/memberResolveConflict.ts b/backend/src/api/public/alerts/memberResolveConflict.ts new file mode 100644 index 0000000000..008116b95a --- /dev/null +++ b/backend/src/api/public/alerts/memberResolveConflict.ts @@ -0,0 +1,28 @@ +import type { Request } from 'express' + +import { ConflictError } from '@crowd/common' + +import { notifyOnce } from '@/api/public/alerts/notifyOnce' + +function notifyMemberResolveConflict(req: Request, memberIds: string[], message: string): void { + const dedupeKey = `member-resolve:${[...memberIds].sort().join(':')}` + + void notifyOnce(req, dedupeKey, 'Member resolve conflict', [ + { + title: 'Members', + text: memberIds.map((id) => `• \`${id}\``).join('\n'), + }, + { title: 'Conflict', text: `*Message:* ${message}` }, + { + title: 'Request', + text: `*Method:* \`${req.method}\`\n*URL:* \`${req.url}\``, + }, + ]) +} + +/** Throws ConflictError for ambiguous resolve and alerts once. */ +export function throwMemberResolveConflict(req: Request, memberIds: string[]): never { + const message = 'Multiple member profiles matched' + notifyMemberResolveConflict(req, memberIds, message) + throw new ConflictError(message, { memberIds }) +} diff --git a/backend/src/api/public/alerts/notifyOnce.ts b/backend/src/api/public/alerts/notifyOnce.ts new file mode 100644 index 0000000000..97340c3897 --- /dev/null +++ b/backend/src/api/public/alerts/notifyOnce.ts @@ -0,0 +1,39 @@ +import type { Request } from 'express' + +import { generateUUIDv4 } from '@crowd/common' +import { RedisCache } from '@crowd/redis' +import { + SlackChannel, + type SlackMessageSection, + SlackPersona, + sendSlackNotification, +} from '@crowd/slack' + +/** Sends a Slack alert once per dedupe key. */ +export async function notifyOnce( + req: Request, + key: string, + title: string, + sections: SlackMessageSection[], +): Promise { + const cache = new RedisCache('public-api-alerts', req.redis, req.log) + const token = generateUUIDv4() + + try { + const result = await cache.setIfNotExistsOrGet(key, token, 60 * 60) + + if (result !== token) { + req.log.info({ key }, 'Skipping duplicate public API alert') + return + } + } catch (err) { + req.log.warn({ err, key }, 'Failed to deduplicate, sending alert') + } + + sendSlackNotification( + SlackChannel.CDP_LFX_SELF_SERVE_ALERTS, + SlackPersona.WARNING_PROPAGATOR, + title, + sections, + ) +} \ No newline at end of file diff --git a/backend/src/api/public/middlewares/errorHandler.ts b/backend/src/api/public/middlewares/errorHandler.ts index 56a9882351..4da3552876 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 { SlackChannel, SlackPersona, sendSlackNotification } from '@crowd/slack' /** @@ -23,30 +17,6 @@ export const errorHandler: ErrorRequestHandler = ( res: Response, _next: NextFunction, ) => { - if (error instanceof ConflictError) { - req.log.warn({ context: error.context }, 'Public API conflict') - sendSlackNotification( - SlackChannel.CDP_LFX_SELF_SERVE_ALERTS, - SlackPersona.WARNING_PROPAGATOR, - `Public API Conflict 409: ${req.method} ${req.url}`, - [ - { - title: 'Request', - text: `*Method:* \`${req.method}\`\n*URL:* \`${req.url}\``, - }, - { - title: 'Conflict', - text: `*Message:* ${error.message}`, - }, - ...(error.context - ? [{ title: 'Context', text: `\`\`\`${JSON.stringify(error.context, null, 2)}\`\`\`` }] - : []), - ], - ) - res.status(error.status).json(error.toJSON()) - return - } - if (error instanceof HttpError) { res.status(error.status).json(error.toJSON()) return diff --git a/backend/src/api/public/v1/members/createMember.ts b/backend/src/api/public/v1/members/createMember.ts index 80df3e8a2d..084d146bea 100644 --- a/backend/src/api/public/v1/members/createMember.ts +++ b/backend/src/api/public/v1/members/createMember.ts @@ -6,6 +6,7 @@ import { getProperDisplayName } from '@crowd/common' import { createMember as insertMember, insertMemberIdentities } from '@crowd/data-access-layer' import { MemberIdentityType } from '@crowd/types' +import { rethrowIdentityConflict } from '@/api/public/alerts/identityConflict' import { optionsQx } from '@/database/sequelizeQueryExecutor' import { created } from '@/utils/api' import { rethrowDbConflict } from '@/utils/err' @@ -65,6 +66,19 @@ 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) { + const identity = identities[0] + rethrowIdentityConflict(req, error, { + platform: identity.platform, + value: + identity.type === MemberIdentityType.EMAIL + ? identity.value.trim().toLowerCase() + : identity.value.trim(), + type: identity.type, + }) + } + return rethrowDbConflict(error) } }) diff --git a/backend/src/api/public/v1/members/identities/createMemberIdentity.ts b/backend/src/api/public/v1/members/identities/createMemberIdentity.ts index 8827a1aff8..62becde6b1 100644 --- a/backend/src/api/public/v1/members/identities/createMemberIdentity.ts +++ b/backend/src/api/public/v1/members/identities/createMemberIdentity.ts @@ -13,9 +13,9 @@ import { } from '@crowd/data-access-layer' import { IMemberIdentity, MemberIdentityType } from '@crowd/types' +import { rethrowIdentityConflict } from '@/api/public/alerts/identityConflict' import { optionsQx } from '@/database/sequelizeQueryExecutor' import { created, ok } from '@/utils/api' -import { rethrowDbConflict } from '@/utils/err' import { validateOrThrow } from '@/utils/validation' const paramsSchema = z.object({ @@ -105,8 +105,12 @@ export async function createMemberIdentity(req: Request, res: Response): Promise } } } catch (error) { - const ctx = { platform: data.platform, value: normalizedValue, type: data.type } - rethrowDbConflict(error, ctx) + rethrowIdentityConflict(req, error, { + memberId, + platform: data.platform, + value: normalizedValue, + type: data.type, + }) } 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 3229f44f48..8539e1894f 100644 --- a/backend/src/api/public/v1/members/identities/verifyMemberIdentity.ts +++ b/backend/src/api/public/v1/members/identities/verifyMemberIdentity.ts @@ -29,9 +29,9 @@ import { MemberUnmergeResult, } from '@crowd/types' +import { rethrowIdentityConflict } from '@/api/public/alerts/identityConflict' import { optionsQx } from '@/database/sequelizeQueryExecutor' import { noContent, ok } from '@/utils/api' -import { rethrowDbConflict } from '@/utils/err' import { validateOrThrow } from '@/utils/validation' const paramsSchema = z.object({ @@ -94,8 +94,12 @@ export async function verifyMemberIdentity(req: Request, res: Response): Promise }) } catch (error) { if (verified) { - const ctx = { platform: identity.platform, value: identity.value, type: identity.type } - rethrowDbConflict(error, ctx) + rethrowIdentityConflict(req, error, { + memberId, + platform: identity.platform, + value: identity.value, + type: identity.type, + }) } throw error diff --git a/backend/src/api/public/v1/members/resolveMember.ts b/backend/src/api/public/v1/members/resolveMember.ts index 0db66b33ae..3d22b5d309 100644 --- a/backend/src/api/public/v1/members/resolveMember.ts +++ b/backend/src/api/public/v1/members/resolveMember.ts @@ -1,10 +1,11 @@ import type { Request, Response } from 'express' import { z } from 'zod' -import { ConflictError, NotFoundError } from '@crowd/common' +import { NotFoundError } from '@crowd/common' import { findMemberIdsByIdentities } from '@crowd/data-access-layer' import { IMemberIdentity, MemberIdentityType, PlatformType } from '@crowd/types' +import { throwMemberResolveConflict } from '@/api/public/alerts/memberResolveConflict' import { optionsQx } from '@/database/sequelizeQueryExecutor' import { ok } from '@/utils/api' import { validateOrThrow } from '@/utils/validation' @@ -38,7 +39,7 @@ export async function resolveMemberByIdentities(req: Request, res: Response): Pr if (memberIds.length === 0) { throw new NotFoundError('Member not found') } else if (memberIds.length > 1) { - throw new ConflictError('Multiple member profiles matched', { memberIds }) + throwMemberResolveConflict(req, memberIds) } const memberId = memberIds[0] From e2b6c68bbb027d42ed915341f8202349c42428ce Mon Sep 17 00:00:00 2001 From: Yeganathan S <63534555+skwowet@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:31:59 +0530 Subject: [PATCH 2/2] fix: eslint broken paranthesis and prettier Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com> --- backend/.eslintrc.js | 1 + backend/src/api/public/alerts/notifyOnce.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/.eslintrc.js b/backend/.eslintrc.js index 6e0768d2af..e7451caae8 100644 --- a/backend/.eslintrc.js +++ b/backend/.eslintrc.js @@ -83,6 +83,7 @@ module.exports = { json: 'always', }, ], + }, }, ], } diff --git a/backend/src/api/public/alerts/notifyOnce.ts b/backend/src/api/public/alerts/notifyOnce.ts index 97340c3897..b35a89acf5 100644 --- a/backend/src/api/public/alerts/notifyOnce.ts +++ b/backend/src/api/public/alerts/notifyOnce.ts @@ -36,4 +36,4 @@ export async function notifyOnce( title, sections, ) -} \ No newline at end of file +}