Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
2 changes: 1 addition & 1 deletion backend/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ module.exports = {
},
rules: {
semi: ['error', 'never'],
'no-void': 'off',
Comment thread
skwowet marked this conversation as resolved.
'prefer-destructuring': ['error', { object: false, array: false }],
'no-param-reassign': 0,
'no-underscore-dangle': 0,
Expand Down Expand Up @@ -82,7 +83,6 @@ module.exports = {
json: 'always',
},
],
},
},
],
}
60 changes: 60 additions & 0 deletions backend/src/api/public/alerts/identityConflict.ts
Original file line number Diff line number Diff line change
@@ -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<IMemberIdentity, 'memberId' | 'platform' | 'value' | 'type'>

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
}
}
28 changes: 28 additions & 0 deletions backend/src/api/public/alerts/memberResolveConflict.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
39 changes: 39 additions & 0 deletions backend/src/api/public/alerts/notifyOnce.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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,
)
}
32 changes: 1 addition & 31 deletions backend/src/api/public/middlewares/errorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions backend/src/api/public/v1/members/createMember.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -61,6 +62,19 @@ export async function createMember(req: Request, res: Response): Promise<void> {

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)
}
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -101,8 +101,12 @@ export async function createMemberIdentity(req: Request, res: Response): Promise
}
}
} catch (error) {
const ctx = { platform: data.platform, value: data.value, type: data.type }
rethrowDbConflict(error, ctx)
rethrowIdentityConflict(req, error, {
memberId,
platform: data.platform,
value: data.value,
type: data.type,
})
}

await touchMemberUpdatedAt(tx, memberId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions backend/src/api/public/v1/members/resolveMember.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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]
Expand Down
Loading