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
106 changes: 106 additions & 0 deletions backend/src/api/public/v1/members/identities/createMemberIdentity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { Request, Response } from 'express'
import { z } from 'zod'

import { captureApiChange, memberEditIdentitiesAction } from '@crowd/audit-logs'
import { ConflictError, NotFoundError } from '@crowd/common'
import {
MemberField,
checkMemberIdentityExistence,
findMemberById,
createMemberIdentity as insertMemberIdentity,
optionsQx,
touchMemberUpdatedAt,
} from '@crowd/data-access-layer'
import { IMemberIdentity, MemberIdentityType } from '@crowd/types'

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

const paramsSchema = z.object({
memberId: z.uuid(),
})

const bodySchema = z
.object({
value: z.string().min(1),
platform: z.string().min(1),
type: z.enum(MemberIdentityType),
source: z.string().min(1),
verified: z.boolean(),
verifiedBy: z.string().optional(),
})
.refine((data) => !data.verified || data.verifiedBy, {
message: 'verifiedBy is required when verified is true',
path: ['verifiedBy'],
})

export async function createMemberIdentity(req: Request, res: Response): Promise<void> {
const { memberId } = validateOrThrow(paramsSchema, req.params)
const data = validateOrThrow(bodySchema, req.body)

const qx = optionsQx(req)

const member = await findMemberById(qx, memberId, [MemberField.ID])
if (!member) {
throw new NotFoundError('Member not found')
}

let result!: IMemberIdentity

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

await qx.tx(async (tx) => {
const existing = await checkMemberIdentityExistence(
tx,
data.value,
data.platform,
data.type,
)

for (const identity of existing) {
if (identity.memberId === memberId) {
throw new ConflictError('Identity already exists on this member')
}

if (identity.verified) {
throw new ConflictError('Identity already verified on another member')
}
}

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,
)

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

captureNewState(result)
}),
)

created(res, {
id: result.id,
value: result.value,
platform: result.platform,
verified: result.verified,
verifiedBy: result.verifiedBy ?? null,
source: result.source ?? null,
createdAt: result.createdAt,
updatedAt: result.updatedAt,
})
}
7 changes: 7 additions & 0 deletions backend/src/api/public/v1/members/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { requireScopes } from '@/api/public/middlewares/requireScopes'
import { safeWrap } from '@/middlewares/errorMiddleware'
import { SCOPES } from '@/security/scopes'

import { createMemberIdentity } from './identities/createMemberIdentity'
import { getMemberIdentities } from './identities/getMemberIdentities'
import { verifyMemberIdentity } from './identities/verifyMemberIdentity'
import { getMemberMaintainerRoles } from './maintainer-roles/getMemberMaintainerRoles'
Expand All @@ -27,6 +28,12 @@ export function membersRouter(): Router {
safeWrap(getMemberIdentities),
)

router.post(
'/:memberId/identities',
requireScopes([SCOPES.WRITE_MEMBER_IDENTITIES]),
safeWrap(createMemberIdentity),
)

router.patch(
'/:memberId/identities/:identityId',
requireScopes([SCOPES.WRITE_MEMBER_IDENTITIES]),
Expand Down
16 changes: 12 additions & 4 deletions backend/src/services/member/memberIdentityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { captureApiChange, memberEditIdentitiesAction } from '@crowd/audit-logs'
import { Error409 } from '@crowd/common'
import { createMemberIdentity, findIdentitiesForMembers, optionsQx } from '@crowd/data-access-layer'
import {
checkIdentityExistance,
checkMemberIdentityExistence,
deleteMemberIdentity,
fetchMemberIdentities,
findMemberIdentityById,
Expand Down Expand Up @@ -58,7 +58,11 @@ export default class MemberIdentityService extends LoggerBase {
const qx = SequelizeRepository.getQueryExecutor(repoOptions)

// Check if identity already exists
const existingIdentities = await checkIdentityExistance(qx, data.value, data.platform)
const existingIdentities = await checkMemberIdentityExistence(
qx,
data.value,
data.platform,
)
if (existingIdentities.length > 0) {
throw new Error409(
this.options.language,
Expand Down Expand Up @@ -126,7 +130,7 @@ export default class MemberIdentityService extends LoggerBase {

// Check if any of the identities already exist
for (const identity of data) {
const existingIdentities = await checkIdentityExistance(
const existingIdentities = await checkMemberIdentityExistence(
qx,
identity.value,
identity.platform,
Expand Down Expand Up @@ -200,7 +204,11 @@ export default class MemberIdentityService extends LoggerBase {
const qx = SequelizeRepository.getQueryExecutor(repoOptions)

// Check if identity already exists
const existingIdentities = await checkIdentityExistance(qx, data.value, data.platform)
const existingIdentities = await checkMemberIdentityExistence(
qx,
data.value,
data.platform,
)
const filteredExistingIdentities = existingIdentities.filter((i) => i.id !== id)
if (filteredExistingIdentities.length > 0) {
throw new Error409(
Expand Down
95 changes: 67 additions & 28 deletions services/libs/data-access-layer/src/members/identities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,22 +49,25 @@ export async function fetchManyMemberIdentities(
)
}

export async function checkIdentityExistance(
export async function checkMemberIdentityExistence(
qx: QueryExecutor,
value: string,
platform: string,
type?: MemberIdentityType,
): Promise<IMemberIdentity[]> {
return await qx.select(
`
SELECT id, "memberId"
SELECT id, "memberId", verified
FROM "memberIdentities"
WHERE "value" = $(value)
AND "platform" = $(platform)
${type ? 'AND "type" = $(type)' : ''}
AND "deletedAt" is null;
`,
{
value,
platform,
type,
},
)
}
Expand Down Expand Up @@ -191,43 +194,79 @@ export async function moveIdentitiesBetweenMembers(
}
}

export async function insertManyMemberIdentities(
qx: QueryExecutor,
identities: NewMemberIdentity[],
failOnConflict: boolean,
returnRows: true,
): Promise<IMemberIdentity[]>
export async function insertManyMemberIdentities(
qx: QueryExecutor,
identities: NewMemberIdentity[],
failOnConflict?: boolean,
returnRows?: false,
): Promise<void>
export async function insertManyMemberIdentities(
qx: QueryExecutor,
identities: NewMemberIdentity[],
failOnConflict = false,
) {
return qx.result(
prepareBulkInsert(
'memberIdentities',
[
'memberId',
'tenantId',
'integrationId',
'platform',
'source',
'sourceId',
'value',
'type',
'verified',
'verifiedBy',
],
identities.map((i) => {
return {
tenantId: DEFAULT_TENANT_ID,
...i,
}
}),
failOnConflict ? undefined : 'DO NOTHING',
),
returnRows = false,
): Promise<IMemberIdentity[] | void> {
const query = prepareBulkInsert(
'memberIdentities',
[
'memberId',
'tenantId',
'integrationId',
'platform',
'source',
'sourceId',
'value',
'type',
'verified',
'verifiedBy',
],
identities.map((i) => {
return {
tenantId: DEFAULT_TENANT_ID,
...i,
}
}),
failOnConflict ? undefined : 'DO NOTHING',
returnRows,
)

if (returnRows) {
return qx.select(query)
}

await qx.result(query)
}

export async function createMemberIdentity(
qx: QueryExecutor,
i: NewMemberIdentity,
failOnConflict: boolean,
returnRows: true,
): Promise<IMemberIdentity>
export async function createMemberIdentity(
qx: QueryExecutor,
i: NewMemberIdentity,
failOnConflict?: boolean,
returnRows?: false,
): Promise<void>
export async function createMemberIdentity(
qx: QueryExecutor,
i: NewMemberIdentity,
failOnConflict = false,
) {
return insertManyMemberIdentities(qx, [i], failOnConflict)
returnRows = false,
): Promise<IMemberIdentity | void> {
if (returnRows) {
const rows = await insertManyMemberIdentities(qx, [i], failOnConflict, true)
return rows[0]
}

await insertManyMemberIdentities(qx, [i], failOnConflict)
}

export async function moveToNewMember(
Expand Down
2 changes: 2 additions & 0 deletions services/libs/data-access-layer/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export function prepareBulkInsert(
columns: string[],
objects: object[],
onConflict?: string,
returnRows = false,
) {
const preparedObjects = objects.map((_, r) => {
return `(${columns.map((_, c) => `$(rows.r${r}_c${c})`).join(',')})`
Expand All @@ -26,6 +27,7 @@ export function prepareBulkInsert(
INSERT INTO $(table:name) (${columns.map((_, i) => `$(columns.col${i}:name)`).join(',')})
VALUES ${preparedObjects.join(',')}
${onConflictClause}
${returnRows ? 'RETURNING *' : ''}
`,
{
table,
Expand Down
Loading