Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
104 changes: 67 additions & 37 deletions backend/src/api/public/v1/members/identities/createMemberIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import { ConflictError, NotFoundError } from '@crowd/common'
import {
MemberField,
findMemberById,
findMemberIdentitiesByValue,
createMemberIdentity as insertMemberIdentity,
optionsQx,
touchMemberUpdatedAt,
updateMemberIdentity,
} from '@crowd/data-access-layer'
import { IMemberIdentity, MemberIdentityType } from '@crowd/types'

import { created } from '@/utils/api'
import { created, ok } from '@/utils/api'
import { validateOrThrow } from '@/utils/validation'

const paramsSchema = z.object({
Expand Down Expand Up @@ -45,60 +47,82 @@ export async function createMemberIdentity(req: Request, res: Response): Promise
}

let result!: IMemberIdentity
let alreadyExisted = false

await captureApiChange(
req,
memberEditIdentitiesAction(memberId, async (captureOldState, captureNewState) => {
captureOldState({})

await qx.tx(async (tx) => {
try {
result = await insertMemberIdentity(
tx,
{
memberId,
platform: data.platform,
value: data.value,
type: data.type,
source: data.source,
verified: data.verified,
verifiedBy: data.verifiedBy,
},
true,
true,
)
} catch (error) {
const constraint =
error.constraint ?? error.original?.constraint ?? error.parent?.constraint

if (constraint === 'uix_memberIdentities_memberId_platform_value_type') {
throw new ConflictError('Identity already exists on this member', {
platform: data.platform,
value: data.value,
type: data.type,
})
const existing = await findMemberIdentitiesByValue(tx, memberId, data.value, {
type: data.type,
})

const exactMatch = existing.find((i) => i.platform === data.platform)
Comment thread
skwowet marked this conversation as resolved.
Outdated

if (exactMatch) {
alreadyExisted = true
result = exactMatch
} else {
try {
result = await insertMemberIdentity(
tx,
{
memberId,
platform: data.platform,
value: data.value,
type: data.type,
source: data.source,
verified: data.verified,
verifiedBy: data.verifiedBy,
},
true,
true,
)
} catch (error) {
const constraint =
error.constraint ?? error.original?.constraint ?? error.parent?.constraint

if (constraint === 'uix_memberIdentities_platform_value_type_verified') {
throw new ConflictError('Identity already verified on another member', {
platform: data.platform,
value: data.value,
type: data.type,
})
}

throw error
}
}

if (constraint === 'uix_memberIdentities_platform_value_type_verified') {
throw new ConflictError('Identity already verified on another member', {
platform: data.platform,
value: data.value,
type: data.type,
})
}
if (data.verified && existing.length > 0) {
await Promise.all(
existing.map((i) =>
updateMemberIdentity(tx, memberId, i.id, {
verified: true,
verifiedBy: data.verifiedBy,
}),
),
)
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

throw error
if (alreadyExisted) {
result = {
...exactMatch,
verified: true,
verifiedBy: data.verifiedBy,
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

// touch member updated at to trigger merge suggestion
await touchMemberUpdatedAt(tx, memberId)
})

captureNewState(result)
}),
)

created(res, {
const response = {
id: result.id,
value: result.value,
platform: result.platform,
Expand All @@ -108,5 +132,11 @@ export async function createMemberIdentity(req: Request, res: Response): Promise
source: result.source ?? null,
createdAt: result.createdAt,
updatedAt: result.updatedAt,
})
}

if (alreadyExisted) {
ok(res, response)
} else {
created(res, response)
}
}
7 changes: 6 additions & 1 deletion backend/src/api/public/v1/members/resolveMember.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,13 @@ export async function resolveMemberByIdentities(req: Request, res: Response): Pr
platform: PlatformType.LFID,
type: MemberIdentityType.USERNAME,
value: lfid,
verified: true,
})),
...(emails?.map((email) => ({ type: MemberIdentityType.EMAIL, value: email })) ?? []),
...(emails?.map((email) => ({
type: MemberIdentityType.EMAIL,
value: email,
verified: true,
})) ?? []),
]

const memberIds = await findMemberIdsByIdentities(qx, identities)
Expand Down
2 changes: 1 addition & 1 deletion services/libs/data-access-layer/src/members/identities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export async function findMemberIdentitiesByValue(
): Promise<IMemberIdentity[]> {
return qx.select(
`
SELECT id, platform, "sourceId", type, value, verified
SELECT *
Comment thread
skwowet marked this conversation as resolved.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Case-sensitive query misses legacy mixed-case identity rows

Medium Severity

The findMemberIdentitiesByValue query uses a case-sensitive comparison (WHERE value = $(value)) while the caller passes a lowercased normalizedValue. Identities created through the old version of this API (which stored data.value without lowercasing) will have mixed-case values that this query won't match. Since the unique index uix_memberIdentities_memberId_platform_value_type is also case-sensitive, the insert of the lowercased value succeeds without constraint violation, silently creating a semantic duplicate on the same member and breaking the idempotency guarantee. The query needs lower(value) = $(value) to detect all existing rows regardless of historical casing.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c0dfc54. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a concern in practice — the data-sink has always written identity values as trimmed lowercase, so mixed-case rows from this API don't exist. The normalization added in this PR covers new writes going forward.

FROM "memberIdentities"
WHERE value = $(value)
AND "memberId" = $(memberId)
Expand Down
Loading