Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
10 changes: 2 additions & 8 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 { alertOnce } from '@/api/public/alerts/alertOnce'

Expand All @@ -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())
Expand Down
18 changes: 15 additions & 3 deletions backend/src/api/public/v1/members/createMember.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -62,9 +66,17 @@ 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) {
if (identities.length === 1 && isMemberIdentityDbConflict(error)) {
const identity = identities[0]
const conflictMemberId = await findMemberIdByVerifiedIdentity(
qx,
identity.platform,
identity.value,
identity.type,
Comment thread
skwowet marked this conversation as resolved.
)

return rethrowDbConflict(error, {
...(conflictMemberId ? { conflictMemberId } : {}),
platform: identity.platform,
value: identity.value,
type: identity.type,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { NotFoundError } from '@crowd/common'
import {
MemberField,
findMemberById,
findMemberIdByVerifiedIdentity,
findMemberIdentitiesByValue,
insertMemberIdentities,
touchMemberUpdatedAt,
Expand All @@ -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({
Expand Down Expand Up @@ -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,
Comment thread
skwowet marked this conversation as resolved.
)

rethrowDbConflict(error, {
memberId,
...(conflictMemberId ? { conflictMemberId } : {}),
platform: data.platform,
value: data.value,
type: data.type,
})
}

throw error
}

await touchMemberUpdatedAt(tx, memberId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
MemberField,
deleteMemberIdentity,
findMemberById,
findMemberIdByVerifiedIdentity,
findMemberIdentityById,
queryActivityRelations,
updateMemberIdentity,
Expand All @@ -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({
Expand Down Expand Up @@ -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,
Comment thread
skwowet marked this conversation as resolved.
)

rethrowDbConflict(error, {
memberId,
...(conflictMemberId ? { conflictMemberId } : {}),
platform: identity.platform,
value: identity.value,
type: identity.type,
Expand Down
4 changes: 4 additions & 0 deletions backend/src/utils/err.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ const DB_CONFLICT_MAP: Record<string, ConflictFactory> = {
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<string, unknown>): never {
const factory = DB_CONFLICT_MAP[getDbConstraint(error) ?? '']

Expand Down
43 changes: 25 additions & 18 deletions services/libs/common/src/errors/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
export abstract class HttpError extends Error {
abstract readonly code: string
abstract readonly status: number
readonly context?: Record<string, unknown>

constructor(message: string) {
constructor(message: string, context?: Record<string, unknown>) {
super(message)
this.name = this.constructor.name
this.context = context
Object.setPrototypeOf(this, new.target.prototype)
}

Expand All @@ -17,6 +19,7 @@ export abstract class HttpError extends Error {
error: {
code: this.code,
message: this.message,
...(this.context ? { context: this.context } : {}),
Comment thread
skwowet marked this conversation as resolved.
},
}
}
Expand All @@ -26,72 +29,76 @@ export class BadRequestError extends HttpError {
readonly code = 'BAD_REQUEST'
readonly status = 400

constructor(message = 'Bad request') {
super(message)
constructor(message = 'Bad request', context?: Record<string, unknown>) {
super(message, context)
}
}

export class UnauthorizedError extends HttpError {
readonly code = 'UNAUTHORIZED'
readonly status = 401

constructor(message = 'Unauthorized') {
super(message)
constructor(message = 'Unauthorized', context?: Record<string, unknown>) {
super(message, context)
}
}

export class ForbiddenError extends HttpError {
readonly code = 'FORBIDDEN'
readonly status = 403

constructor(message = 'Forbidden') {
super(message)
constructor(message = 'Forbidden', context?: Record<string, unknown>) {
super(message, context)
}
}

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<string, unknown>,
) {
super(message, context)
}
}

export class NotFoundError extends HttpError {
readonly code = 'NOT_FOUND'
readonly status = 404

constructor(message = 'Not found') {
super(message)
constructor(message = 'Not found', context?: Record<string, unknown>) {
super(message, context)
}
}

export class ConflictError extends HttpError {
readonly code = 'CONFLICT'
readonly status = 409
readonly context?: Record<string, unknown>

constructor(message = 'Conflict', context?: Record<string, unknown>) {
super(message)
this.context = context
super(message, context)
}
}

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<string, unknown>,
) {
super(message, context)
}
}

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<string, unknown>) {
super(message, context)
}
}
Loading