Skip to content

Commit a35c3b0

Browse files
authored
feat: add public endpoint to create member identity (CM-1070) (#3969)
Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
1 parent 8b5726a commit a35c3b0

5 files changed

Lines changed: 194 additions & 32 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import type { Request, Response } from 'express'
2+
import { z } from 'zod'
3+
4+
import { captureApiChange, memberEditIdentitiesAction } from '@crowd/audit-logs'
5+
import { ConflictError, NotFoundError } from '@crowd/common'
6+
import {
7+
MemberField,
8+
checkMemberIdentityExistence,
9+
findMemberById,
10+
createMemberIdentity as insertMemberIdentity,
11+
optionsQx,
12+
touchMemberUpdatedAt,
13+
} from '@crowd/data-access-layer'
14+
import { IMemberIdentity, MemberIdentityType } from '@crowd/types'
15+
16+
import { created } from '@/utils/api'
17+
import { validateOrThrow } from '@/utils/validation'
18+
19+
const paramsSchema = z.object({
20+
memberId: z.uuid(),
21+
})
22+
23+
const bodySchema = z
24+
.object({
25+
value: z.string().min(1),
26+
platform: z.string().min(1),
27+
type: z.enum(MemberIdentityType),
28+
source: z.string().min(1),
29+
verified: z.boolean(),
30+
verifiedBy: z.string().optional(),
31+
})
32+
.refine((data) => !data.verified || data.verifiedBy, {
33+
message: 'verifiedBy is required when verified is true',
34+
path: ['verifiedBy'],
35+
})
36+
37+
export async function createMemberIdentity(req: Request, res: Response): Promise<void> {
38+
const { memberId } = validateOrThrow(paramsSchema, req.params)
39+
const data = validateOrThrow(bodySchema, req.body)
40+
41+
const qx = optionsQx(req)
42+
43+
const member = await findMemberById(qx, memberId, [MemberField.ID])
44+
if (!member) {
45+
throw new NotFoundError('Member not found')
46+
}
47+
48+
let result!: IMemberIdentity
49+
50+
await captureApiChange(
51+
req,
52+
memberEditIdentitiesAction(memberId, async (captureOldState, captureNewState) => {
53+
captureOldState({})
54+
55+
await qx.tx(async (tx) => {
56+
const existing = await checkMemberIdentityExistence(
57+
tx,
58+
data.value,
59+
data.platform,
60+
data.type,
61+
)
62+
63+
for (const identity of existing) {
64+
if (identity.memberId === memberId) {
65+
throw new ConflictError('Identity already exists on this member')
66+
}
67+
68+
if (identity.verified) {
69+
throw new ConflictError('Identity already verified on another member')
70+
}
71+
}
72+
73+
result = await insertMemberIdentity(
74+
tx,
75+
{
76+
memberId,
77+
platform: data.platform,
78+
value: data.value,
79+
type: data.type,
80+
source: data.source,
81+
verified: data.verified,
82+
verifiedBy: data.verifiedBy,
83+
},
84+
true,
85+
true,
86+
)
87+
88+
// touch member updated at to trigger merge suggestion
89+
await touchMemberUpdatedAt(tx, memberId)
90+
})
91+
92+
captureNewState(result)
93+
}),
94+
)
95+
96+
created(res, {
97+
id: result.id,
98+
value: result.value,
99+
platform: result.platform,
100+
verified: result.verified,
101+
verifiedBy: result.verifiedBy ?? null,
102+
source: result.source ?? null,
103+
createdAt: result.createdAt,
104+
updatedAt: result.updatedAt,
105+
})
106+
}

backend/src/api/public/v1/members/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { requireScopes } from '@/api/public/middlewares/requireScopes'
44
import { safeWrap } from '@/middlewares/errorMiddleware'
55
import { SCOPES } from '@/security/scopes'
66

7+
import { createMemberIdentity } from './identities/createMemberIdentity'
78
import { getMemberIdentities } from './identities/getMemberIdentities'
89
import { verifyMemberIdentity } from './identities/verifyMemberIdentity'
910
import { getMemberMaintainerRoles } from './maintainer-roles/getMemberMaintainerRoles'
@@ -27,6 +28,12 @@ export function membersRouter(): Router {
2728
safeWrap(getMemberIdentities),
2829
)
2930

31+
router.post(
32+
'/:memberId/identities',
33+
requireScopes([SCOPES.WRITE_MEMBER_IDENTITIES]),
34+
safeWrap(createMemberIdentity),
35+
)
36+
3037
router.patch(
3138
'/:memberId/identities/:identityId',
3239
requireScopes([SCOPES.WRITE_MEMBER_IDENTITIES]),

backend/src/services/member/memberIdentityService.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { captureApiChange, memberEditIdentitiesAction } from '@crowd/audit-logs'
55
import { Error409 } from '@crowd/common'
66
import { createMemberIdentity, findIdentitiesForMembers, optionsQx } from '@crowd/data-access-layer'
77
import {
8-
checkIdentityExistance,
8+
checkMemberIdentityExistence,
99
deleteMemberIdentity,
1010
fetchMemberIdentities,
1111
findMemberIdentityById,
@@ -58,7 +58,11 @@ export default class MemberIdentityService extends LoggerBase {
5858
const qx = SequelizeRepository.getQueryExecutor(repoOptions)
5959

6060
// Check if identity already exists
61-
const existingIdentities = await checkIdentityExistance(qx, data.value, data.platform)
61+
const existingIdentities = await checkMemberIdentityExistence(
62+
qx,
63+
data.value,
64+
data.platform,
65+
)
6266
if (existingIdentities.length > 0) {
6367
throw new Error409(
6468
this.options.language,
@@ -126,7 +130,7 @@ export default class MemberIdentityService extends LoggerBase {
126130

127131
// Check if any of the identities already exist
128132
for (const identity of data) {
129-
const existingIdentities = await checkIdentityExistance(
133+
const existingIdentities = await checkMemberIdentityExistence(
130134
qx,
131135
identity.value,
132136
identity.platform,
@@ -200,7 +204,11 @@ export default class MemberIdentityService extends LoggerBase {
200204
const qx = SequelizeRepository.getQueryExecutor(repoOptions)
201205

202206
// Check if identity already exists
203-
const existingIdentities = await checkIdentityExistance(qx, data.value, data.platform)
207+
const existingIdentities = await checkMemberIdentityExistence(
208+
qx,
209+
data.value,
210+
data.platform,
211+
)
204212
const filteredExistingIdentities = existingIdentities.filter((i) => i.id !== id)
205213
if (filteredExistingIdentities.length > 0) {
206214
throw new Error409(

services/libs/data-access-layer/src/members/identities.ts

Lines changed: 67 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -49,22 +49,25 @@ export async function fetchManyMemberIdentities(
4949
)
5050
}
5151

52-
export async function checkIdentityExistance(
52+
export async function checkMemberIdentityExistence(
5353
qx: QueryExecutor,
5454
value: string,
5555
platform: string,
56+
type?: MemberIdentityType,
5657
): Promise<IMemberIdentity[]> {
5758
return await qx.select(
5859
`
59-
SELECT id, "memberId"
60+
SELECT id, "memberId", verified
6061
FROM "memberIdentities"
6162
WHERE "value" = $(value)
6263
AND "platform" = $(platform)
64+
${type ? 'AND "type" = $(type)' : ''}
6365
AND "deletedAt" is null;
6466
`,
6567
{
6668
value,
6769
platform,
70+
type,
6871
},
6972
)
7073
}
@@ -191,43 +194,79 @@ export async function moveIdentitiesBetweenMembers(
191194
}
192195
}
193196

197+
export async function insertManyMemberIdentities(
198+
qx: QueryExecutor,
199+
identities: NewMemberIdentity[],
200+
failOnConflict: boolean,
201+
returnRows: true,
202+
): Promise<IMemberIdentity[]>
203+
export async function insertManyMemberIdentities(
204+
qx: QueryExecutor,
205+
identities: NewMemberIdentity[],
206+
failOnConflict?: boolean,
207+
returnRows?: false,
208+
): Promise<void>
194209
export async function insertManyMemberIdentities(
195210
qx: QueryExecutor,
196211
identities: NewMemberIdentity[],
197212
failOnConflict = false,
198-
) {
199-
return qx.result(
200-
prepareBulkInsert(
201-
'memberIdentities',
202-
[
203-
'memberId',
204-
'tenantId',
205-
'integrationId',
206-
'platform',
207-
'source',
208-
'sourceId',
209-
'value',
210-
'type',
211-
'verified',
212-
'verifiedBy',
213-
],
214-
identities.map((i) => {
215-
return {
216-
tenantId: DEFAULT_TENANT_ID,
217-
...i,
218-
}
219-
}),
220-
failOnConflict ? undefined : 'DO NOTHING',
221-
),
213+
returnRows = false,
214+
): Promise<IMemberIdentity[] | void> {
215+
const query = prepareBulkInsert(
216+
'memberIdentities',
217+
[
218+
'memberId',
219+
'tenantId',
220+
'integrationId',
221+
'platform',
222+
'source',
223+
'sourceId',
224+
'value',
225+
'type',
226+
'verified',
227+
'verifiedBy',
228+
],
229+
identities.map((i) => {
230+
return {
231+
tenantId: DEFAULT_TENANT_ID,
232+
...i,
233+
}
234+
}),
235+
failOnConflict ? undefined : 'DO NOTHING',
236+
returnRows,
222237
)
238+
239+
if (returnRows) {
240+
return qx.select(query)
241+
}
242+
243+
await qx.result(query)
223244
}
224245

246+
export async function createMemberIdentity(
247+
qx: QueryExecutor,
248+
i: NewMemberIdentity,
249+
failOnConflict: boolean,
250+
returnRows: true,
251+
): Promise<IMemberIdentity>
252+
export async function createMemberIdentity(
253+
qx: QueryExecutor,
254+
i: NewMemberIdentity,
255+
failOnConflict?: boolean,
256+
returnRows?: false,
257+
): Promise<void>
225258
export async function createMemberIdentity(
226259
qx: QueryExecutor,
227260
i: NewMemberIdentity,
228261
failOnConflict = false,
229-
) {
230-
return insertManyMemberIdentities(qx, [i], failOnConflict)
262+
returnRows = false,
263+
): Promise<IMemberIdentity | void> {
264+
if (returnRows) {
265+
const rows = await insertManyMemberIdentities(qx, [i], failOnConflict, true)
266+
return rows[0]
267+
}
268+
269+
await insertManyMemberIdentities(qx, [i], failOnConflict)
231270
}
232271

233272
export async function moveToNewMember(

services/libs/data-access-layer/src/utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export function prepareBulkInsert(
1111
columns: string[],
1212
objects: object[],
1313
onConflict?: string,
14+
returnRows = false,
1415
) {
1516
const preparedObjects = objects.map((_, r) => {
1617
return `(${columns.map((_, c) => `$(rows.r${r}_c${c})`).join(',')})`
@@ -26,6 +27,7 @@ export function prepareBulkInsert(
2627
INSERT INTO $(table:name) (${columns.map((_, i) => `$(columns.col${i}:name)`).join(',')})
2728
VALUES ${preparedObjects.join(',')}
2829
${onConflictClause}
30+
${returnRows ? 'RETURNING *' : ''}
2931
`,
3032
{
3133
table,

0 commit comments

Comments
 (0)