@@ -6,13 +6,15 @@ import { ConflictError, NotFoundError } from '@crowd/common'
66import {
77 MemberField ,
88 findMemberById ,
9+ findMemberIdentitiesByValue ,
910 createMemberIdentity as insertMemberIdentity ,
1011 optionsQx ,
1112 touchMemberUpdatedAt ,
13+ updateMemberIdentity ,
1214} from '@crowd/data-access-layer'
1315import { IMemberIdentity , MemberIdentityType } from '@crowd/types'
1416
15- import { created } from '@/utils/api'
17+ import { created , ok } from '@/utils/api'
1618import { validateOrThrow } from '@/utils/validation'
1719
1820const paramsSchema = z . object ( {
@@ -33,72 +35,103 @@ const bodySchema = z
3335 path : [ 'verifiedBy' ] ,
3436 } )
3537
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+
3657export async function createMemberIdentity ( req : Request , res : Response ) : Promise < void > {
3758 const { memberId } = validateOrThrow ( paramsSchema , req . params )
3859 const data = validateOrThrow ( bodySchema , req . body )
3960
4061 const qx = optionsQx ( req )
41-
4262 const member = await findMemberById ( qx , memberId , [ MemberField . ID ] )
4363 if ( ! member ) {
4464 throw new NotFoundError ( 'Member not found' )
4565 }
4666
67+ // The data-sink writes identity values as trimmed lowercase, so normalize here
68+ // to keep idempotency checks reliable against existing rows.
69+ const normalizedValue = data . value . trim ( ) . toLowerCase ( )
70+ const conflictContext = { platform : data . platform , value : normalizedValue , type : data . type }
71+
4772 let result ! : IMemberIdentity
73+ let alreadyExisted = false
4874
4975 await captureApiChange (
5076 req ,
5177 memberEditIdentitiesAction ( memberId , async ( captureOldState , captureNewState ) => {
5278 captureOldState ( { } )
5379
5480 await qx . tx ( async ( tx ) => {
81+ const existing = await findMemberIdentitiesByValue ( tx , memberId , normalizedValue , {
82+ type : data . type ,
83+ } )
84+ const exactMatch = existing . find ( ( i ) => i . platform === data . platform )
85+
5586 try {
56- result = await insertMemberIdentity (
57- tx ,
58- {
59- memberId,
60- platform : data . platform ,
61- value : data . value ,
62- type : data . type ,
63- source : data . source ,
64- verified : data . verified ,
65- verifiedBy : data . verifiedBy ,
66- } ,
67- true ,
68- true ,
69- )
70- } catch ( error ) {
71- const constraint =
72- error . constraint ?? error . original ?. constraint ?? error . parent ?. constraint
73-
74- if ( constraint === 'uix_memberIdentities_memberId_platform_value_type' ) {
75- throw new ConflictError ( 'Identity already exists on this member' , {
76- platform : data . platform ,
77- value : data . value ,
78- type : data . type ,
79- } )
87+ if ( exactMatch ) {
88+ alreadyExisted = true
89+ result = exactMatch
90+ } else {
91+ result = await insertMemberIdentity (
92+ tx ,
93+ {
94+ memberId,
95+ platform : data . platform ,
96+ value : normalizedValue ,
97+ type : data . type ,
98+ source : data . source ,
99+ verified : data . verified ,
100+ verifiedBy : data . verifiedBy ,
101+ } ,
102+ true ,
103+ true ,
104+ )
80105 }
81106
82- if ( constraint === 'uix_memberIdentities_platform_value_type_verified' ) {
83- throw new ConflictError ( 'Identity already verified on another member' , {
84- platform : data . platform ,
85- value : data . value ,
86- type : data . type ,
87- } )
107+ // A verified identity confirms the same value for this member, so keep same-value
108+ // identities in sync instead of leaving stale unverified duplicates behind.
109+ if ( data . verified && existing . length > 0 ) {
110+ const updatedResults : IMemberIdentity [ ] = [ ]
111+ for ( const identity of existing ) {
112+ const updated = await updateMemberIdentity ( tx , memberId , identity . id , {
113+ verified : true ,
114+ verifiedBy : data . verifiedBy ,
115+ } )
116+ if ( updated ) updatedResults . push ( updated )
117+ }
118+
119+ if ( alreadyExisted ) {
120+ result = updatedResults . find ( ( r ) => r . id === exactMatch . id ) ?? result
121+ }
88122 }
89-
90- throw error
123+ } catch ( error ) {
124+ throwIdentityConflict ( error as DbConstraintError , conflictContext )
91125 }
92126
93- // touch member updated at to trigger merge suggestion
94127 await touchMemberUpdatedAt ( tx , memberId )
95128 } )
96129
97130 captureNewState ( result )
98131 } ) ,
99132 )
100133
101- created ( res , {
134+ const response = {
102135 id : result . id ,
103136 value : result . value ,
104137 platform : result . platform ,
@@ -108,5 +141,11 @@ export async function createMemberIdentity(req: Request, res: Response): Promise
108141 source : result . source ?? null ,
109142 createdAt : result . createdAt ,
110143 updatedAt : result . updatedAt ,
111- } )
144+ }
145+
146+ if ( alreadyExisted ) {
147+ ok ( res , response )
148+ } else {
149+ created ( res , response )
150+ }
112151}
0 commit comments