Skip to content

Commit b765ade

Browse files
authored
feat: add public endpoint to create member (CM-1224) (#4206)
Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
1 parent d5bbef7 commit b765ade

10 files changed

Lines changed: 258 additions & 44 deletions

File tree

backend/src/api/public/openapi.yaml

Lines changed: 117 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,86 @@ paths:
4646
# ──────────────────────────────────────────────
4747
# Members
4848
# ──────────────────────────────────────────────
49+
/members:
50+
post:
51+
operationId: createMember
52+
summary: Create a member profile
53+
description: >
54+
Create a new member profile in CDP with one or more identities.
55+
tags:
56+
- Members
57+
security:
58+
- OAuth2Bearer:
59+
- write:members
60+
requestBody:
61+
required: true
62+
content:
63+
application/json:
64+
schema:
65+
type: object
66+
required:
67+
- displayName
68+
- identities
69+
properties:
70+
displayName:
71+
type: string
72+
minLength: 1
73+
description: Display name for the member profile.
74+
identities:
75+
type: array
76+
minItems: 1
77+
description: Initial identities for the member.
78+
items:
79+
$ref: '#/components/schemas/MemberIdentityInput'
80+
example:
81+
displayName: Jane Doe
82+
identities:
83+
- value: abc123
84+
platform: lfid
85+
type: username
86+
source: lfxOne
87+
verified: true
88+
verifiedBy: jane@lfx.dev
89+
responses:
90+
'201':
91+
description: Member created successfully.
92+
content:
93+
application/json:
94+
schema:
95+
type: object
96+
required:
97+
- memberId
98+
properties:
99+
memberId:
100+
type: string
101+
format: uuid
102+
example:
103+
memberId: 550e8400-e29b-41d4-a716-446655440000
104+
'400':
105+
$ref: '#/components/responses/BadRequest'
106+
'401':
107+
$ref: '#/components/responses/Unauthorized'
108+
'403':
109+
$ref: '#/components/responses/Forbidden'
110+
'409':
111+
description: Identity already exists on another member.
112+
content:
113+
application/json:
114+
schema:
115+
$ref: '#/components/schemas/HttpError'
116+
example:
117+
error:
118+
code: CONFLICT
119+
message: Identity already exists on another member
120+
49121
/members/resolve:
50122
post:
51123
operationId: resolveMember
52124
summary: Resolve a CDP member profile
53125
description: >
54-
Resolve a `memberId` from one or more identities. LFX One should always
55-
call this first to obtain the CDP member ID before using other endpoints.
56-
Returns 404 if no matching profile exists, or 409 if the provided
57-
identities belong to different profiles.
126+
Resolve memberId from identities. LFX One should always make a first
127+
request to this API to retrieve the corresponding memberId from CDP.
128+
If a member is found, use it. If not, create a member.
58129
tags:
59130
- Members
60131
security:
@@ -107,7 +178,7 @@ paths:
107178
'403':
108179
$ref: '#/components/responses/Forbidden'
109180
'404':
110-
description: No matching member profile found.
181+
description: Profile not found.
111182
content:
112183
application/json:
113184
schema:
@@ -117,15 +188,15 @@ paths:
117188
code: NOT_FOUND
118189
message: Member not found
119190
'409':
120-
description: The provided identities belong to different member profiles.
191+
description: Multiple member profiles matched.
121192
content:
122193
application/json:
123194
schema:
124195
$ref: '#/components/schemas/HttpError'
125196
example:
126197
error:
127198
code: CONFLICT
128-
message: Conflicting identities
199+
message: Multiple member profiles matched
129200

130201
# ──────────────────────────────────────────────
131202
# Member Identities
@@ -236,15 +307,15 @@ paths:
236307
'404':
237308
$ref: '#/components/responses/MemberNotFound'
238309
'409':
239-
description: Identity already exists on this member or is verified on another.
310+
description: Identity already exists on another member.
240311
content:
241312
application/json:
242313
schema:
243314
$ref: '#/components/schemas/HttpError'
244315
example:
245316
error:
246317
code: CONFLICT
247-
message: Identity already exists on this member
318+
message: Identity already exists on another member
248319

249320
/members/{memberId}/identities/{identityId}:
250321
patch:
@@ -320,15 +391,15 @@ paths:
320391
schema:
321392
$ref: '#/components/schemas/HttpError'
322393
'409':
323-
description: Identity is already verified on another member.
394+
description: Identity already exists on another member.
324395
content:
325396
application/json:
326397
schema:
327398
$ref: '#/components/schemas/HttpError'
328399
example:
329400
error:
330401
code: CONFLICT
331-
message: Identity already verified on another member
402+
message: Identity already exists on another member
332403

333404
# ──────────────────────────────────────────────
334405
# Maintainer Roles
@@ -972,6 +1043,7 @@ components:
9721043
tokenUrl: https://linuxfoundation.auth0.com/oauth/token
9731044
scopes:
9741045
read:members: Read member profiles
1046+
write:members: Create member profiles
9751047
read:member-identities: Read member identities
9761048
write:member-identities: Create and verify member identities
9771049
read:maintainer-roles: Read maintainer roles
@@ -1008,6 +1080,40 @@ components:
10081080
format: uuid
10091081

10101082
schemas:
1083+
MemberIdentityInput:
1084+
type: object
1085+
required:
1086+
- value
1087+
- platform
1088+
- type
1089+
- source
1090+
- verified
1091+
properties:
1092+
value:
1093+
type: string
1094+
minLength: 1
1095+
description: Identity value (e.g. username, email address).
1096+
platform:
1097+
type: string
1098+
minLength: 1
1099+
description: Platform name (e.g. github, lfid).
1100+
type:
1101+
type: string
1102+
enum:
1103+
- username
1104+
- email
1105+
description: Identity type.
1106+
source:
1107+
type: string
1108+
minLength: 1
1109+
description: Source system that created this identity.
1110+
verified:
1111+
type: boolean
1112+
description: Whether the identity is verified.
1113+
verifiedBy:
1114+
type: string
1115+
description: Required when `verified` is true. Identifier of who verified.
1116+
10111117
MemberIdentity:
10121118
type: object
10131119
required:
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import type { Request, Response } from 'express'
2+
import { z } from 'zod'
3+
4+
import { captureApiChange, memberCreateAction, memberEditIdentitiesAction } from '@crowd/audit-logs'
5+
import { getProperDisplayName } from '@crowd/common'
6+
import {
7+
insertManyMemberIdentities,
8+
createMember as insertMember,
9+
optionsQx,
10+
} from '@crowd/data-access-layer'
11+
import { MemberIdentityType } from '@crowd/types'
12+
13+
import { created } from '@/utils/api'
14+
import { rethrowDbConflict } from '@/utils/err'
15+
import { validateOrThrow } from '@/utils/validation'
16+
17+
const bodySchema = z.object({
18+
displayName: z.string().trim().min(1),
19+
identities: z
20+
.array(
21+
z
22+
.object({
23+
value: z.string().min(1),
24+
platform: z.string().min(1),
25+
type: z.enum(MemberIdentityType),
26+
source: z.string().min(1),
27+
verified: z.boolean(),
28+
verifiedBy: z.string().optional(),
29+
})
30+
.refine((data) => !data.verified || data.verifiedBy, {
31+
message: 'verifiedBy is required when verified is true',
32+
path: ['verifiedBy'],
33+
}),
34+
)
35+
.min(1),
36+
})
37+
38+
export async function createMember(req: Request, res: Response): Promise<void> {
39+
const { displayName, identities } = validateOrThrow(bodySchema, req.body)
40+
const qx = optionsQx(req)
41+
42+
const normalizedDisplayName = getProperDisplayName(displayName)
43+
44+
const { dbMember, dbIdentities } = await qx.tx(async (tx) => {
45+
try {
46+
const dbMember = await insertMember(tx, {
47+
displayName: normalizedDisplayName,
48+
joinedAt: new Date().toISOString(),
49+
attributes: {},
50+
reach: {},
51+
// OpenSearch sync only keeps members that either have activities or have manuallyCreated set.
52+
manuallyCreated: true,
53+
})
54+
55+
const dbIdentities = await insertManyMemberIdentities(
56+
tx,
57+
identities.map((identity) => ({
58+
...identity,
59+
memberId: dbMember.id,
60+
value: identity.value.trim().toLowerCase(),
61+
})),
62+
true,
63+
true,
64+
)
65+
66+
return { dbMember, dbIdentities }
67+
} catch (error) {
68+
return rethrowDbConflict(error)
69+
}
70+
})
71+
72+
await captureApiChange(
73+
req,
74+
memberCreateAction(dbMember.id, async (captureNewState) => {
75+
captureNewState({
76+
memberId: dbMember.id,
77+
displayName: dbMember.displayName,
78+
manuallyCreated: true,
79+
})
80+
}),
81+
)
82+
83+
await captureApiChange(
84+
req,
85+
memberEditIdentitiesAction(dbMember.id, async (captureOldState, captureNewState) => {
86+
captureOldState({})
87+
captureNewState(dbIdentities)
88+
}),
89+
)
90+
91+
created(res, { memberId: dbMember.id })
92+
}

backend/src/api/public/v1/members/identities/createMemberIdentity.ts

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Request, Response } from 'express'
22
import { z } from 'zod'
33

44
import { captureApiChange, memberEditIdentitiesAction } from '@crowd/audit-logs'
5-
import { ConflictError, NotFoundError } from '@crowd/common'
5+
import { NotFoundError } from '@crowd/common'
66
import {
77
MemberField,
88
findMemberById,
@@ -15,6 +15,7 @@ import {
1515
import { IMemberIdentity, MemberIdentityType } from '@crowd/types'
1616

1717
import { created, ok } from '@/utils/api'
18+
import { rethrowDbConflict } from '@/utils/err'
1819
import { validateOrThrow } from '@/utils/validation'
1920

2021
const paramsSchema = z.object({
@@ -35,25 +36,6 @@ const bodySchema = z
3536
path: ['verifiedBy'],
3637
})
3738

38-
type DbConstraintError = Error & {
39-
constraint?: string
40-
original?: { constraint?: string }
41-
parent?: { constraint?: string }
42-
}
43-
44-
function throwIdentityConflict(
45-
error: DbConstraintError,
46-
data: { platform: string; value: string; type: MemberIdentityType },
47-
): never {
48-
const constraint = error.constraint ?? error.original?.constraint ?? error.parent?.constraint
49-
50-
if (constraint === 'uix_memberIdentities_platform_value_type_verified') {
51-
throw new ConflictError('Identity already verified on another member', data)
52-
}
53-
54-
throw error
55-
}
56-
5739
export async function createMemberIdentity(req: Request, res: Response): Promise<void> {
5840
const { memberId } = validateOrThrow(paramsSchema, req.params)
5941
const data = validateOrThrow(bodySchema, req.body)
@@ -67,7 +49,6 @@ export async function createMemberIdentity(req: Request, res: Response): Promise
6749
// The data-sink writes identity values as trimmed lowercase, so normalize here
6850
// to keep idempotency checks reliable against existing rows.
6951
const normalizedValue = data.value.trim().toLowerCase()
70-
const conflictContext = { platform: data.platform, value: normalizedValue, type: data.type }
7152

7253
let result!: IMemberIdentity
7354
let alreadyExisted = false
@@ -121,7 +102,8 @@ export async function createMemberIdentity(req: Request, res: Response): Promise
121102
}
122103
}
123104
} catch (error) {
124-
throwIdentityConflict(error as DbConstraintError, conflictContext)
105+
const ctx = { platform: data.platform, value: normalizedValue, type: data.type }
106+
rethrowDbConflict(error, ctx)
125107
}
126108

127109
await touchMemberUpdatedAt(tx, memberId)

backend/src/api/public/v1/members/identities/verifyMemberIdentity.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
memberUnmergeAction,
77
memberVerifyIdentityAction,
88
} from '@crowd/audit-logs'
9-
import { ConflictError, InternalError, NotFoundError } from '@crowd/common'
9+
import { InternalError, NotFoundError } from '@crowd/common'
1010
import {
1111
invalidateMemberQueryCache,
1212
prepareMemberUnmerge,
@@ -31,6 +31,7 @@ import {
3131
} from '@crowd/types'
3232

3333
import { noContent, ok } from '@/utils/api'
34+
import { rethrowDbConflict } from '@/utils/err'
3435
import { validateOrThrow } from '@/utils/validation'
3536

3637
const paramsSchema = z.object({
@@ -92,15 +93,9 @@ export async function verifyMemberIdentity(req: Request, res: Response): Promise
9293
verifiedBy,
9394
})
9495
} catch (error) {
95-
const constraint =
96-
error.constraint ?? error.original?.constraint ?? error.parent?.constraint
97-
98-
if (verified && constraint === 'uix_memberIdentities_platform_value_type_verified') {
99-
throw new ConflictError('Identity already verified on another member', {
100-
platform: identity.platform,
101-
value: identity.value,
102-
type: identity.type,
103-
})
96+
if (verified) {
97+
const ctx = { platform: identity.platform, value: identity.value, type: identity.type }
98+
rethrowDbConflict(error, ctx)
10499
}
105100

106101
throw error

0 commit comments

Comments
 (0)