@@ -28,8 +28,9 @@ import { getEntraIdToken } from "api/functions/entraId.js";
2828import  {  genericConfig ,  roleArns  }  from  "common/config.js" ; 
2929import  {  getRoleCredentials  }  from  "api/functions/sts.js" ; 
3030import  {  SecretsManagerClient  }  from  "@aws-sdk/client-secrets-manager" ; 
31- import  {  DynamoDBClient  }  from  "@aws-sdk/client-dynamodb" ; 
31+ import  {  BatchGetItemCommand ,   DynamoDBClient  }  from  "@aws-sdk/client-dynamodb" ; 
3232import  {  AppRoles  }  from  "common/roles.js" ; 
33+ import  {  marshall ,  unmarshall  }  from  "@aws-sdk/util-dynamodb" ; 
3334
3435const  membershipV2Plugin : FastifyPluginAsync  =  async  ( fastify ,  _options )  =>  { 
3536  const  getAuthorizedClients  =  async  ( )  =>  { 
@@ -160,6 +161,213 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
160161        ) ; 
161162      } , 
162163    ) ; 
164+     fastify . withTypeProvider < FastifyZodOpenApiTypeProvider > ( ) . post ( 
165+       "/verifyBatchOfMembers" , 
166+       { 
167+         schema : withRoles ( 
168+           [ 
169+             AppRoles . VIEW_INTERNAL_MEMBERSHIP_LIST , 
170+             AppRoles . VIEW_EXTERNAL_MEMBERSHIP_LIST , 
171+           ] , 
172+           withTags ( [ "Membership" ] ,  { 
173+             body : z . array ( illinoisNetId ) . nonempty ( ) . max ( 500 ) , 
174+             querystring : z . object ( { 
175+               list : z . string ( ) . min ( 1 ) . optional ( ) . meta ( { 
176+                 example : "built" , 
177+                 description :
178+                   "Membership list to check from (defaults to ACM Paid Member list)." , 
179+               } ) , 
180+             } ) , 
181+             summary :
182+               "Check a batch of NetIDs for ACM @ UIUC paid membership (or partner organization membership) status." , 
183+             response : { 
184+               200 : { 
185+                 description : "List membership status." , 
186+                 content : { 
187+                   "application/json" : { 
188+                     schema : z 
189+                       . object ( { 
190+                         members : z . array ( illinoisNetId ) , 
191+                         notMembers : z . array ( illinoisNetId ) , 
192+                         list : z . optional ( z . string ( ) . min ( 1 ) ) , 
193+                       } ) 
194+                       . meta ( { 
195+                         example : { 
196+                           members : [ "rjjones" ] , 
197+                           notMembers : [ "isbell" ] , 
198+                           list : "built" , 
199+                         } , 
200+                       } ) , 
201+                   } , 
202+                 } , 
203+               } , 
204+             } , 
205+           } ) , 
206+         ) , 
207+         onRequest : async  ( request ,  reply )  =>  { 
208+           await  fastify . authorizeFromSchema ( request ,  reply ) ; 
209+           if  ( ! request . userRoles )  { 
210+             throw  new  InternalServerError ( { } ) ; 
211+           } 
212+           const  list  =  request . query . list  ||  "acmpaid" ; 
213+           if  ( 
214+             list  ===  "acmpaid"  && 
215+             ! request . userRoles . has ( AppRoles . VIEW_INTERNAL_MEMBERSHIP_LIST ) 
216+           )  { 
217+             throw  new  UnauthorizedError ( { } ) ; 
218+           } 
219+           if  ( 
220+             list  !==  "acmpaid"  && 
221+             ! request . userRoles . has ( AppRoles . VIEW_EXTERNAL_MEMBERSHIP_LIST ) 
222+           )  { 
223+             throw  new  UnauthorizedError ( { } ) ; 
224+           } 
225+         } , 
226+       } , 
227+       async  ( request ,  reply )  =>  { 
228+         const  list  =  request . query . list  ||  "acmpaid" ; 
229+         let  netIdsToCheck  =  [ 
230+           ...new  Set ( request . body . map ( ( id )  =>  id . toLowerCase ( ) ) ) , 
231+         ] ; 
232+ 
233+         const  members  =  new  Set < string > ( ) ; 
234+         const  notMembers  =  new  Set < string > ( ) ; 
235+ 
236+         const  cacheKeys  =  netIdsToCheck . map ( ( id )  =>  `membership:${ id }  :${ list }  ` ) ; 
237+         if  ( cacheKeys . length  >  0 )  { 
238+           const  cachedResults  =  await  fastify . redisClient . mget ( cacheKeys ) ; 
239+           const  remainingNetIds : string [ ]  =  [ ] ; 
240+           cachedResults . forEach ( ( result ,  index )  =>  { 
241+             const  netId  =  netIdsToCheck [ index ] ; 
242+             if  ( result )  { 
243+               const  {  isMember }  =  JSON . parse ( result )  as  {  isMember : boolean  } ; 
244+               if  ( isMember )  { 
245+                 members . add ( netId ) ; 
246+               }  else  { 
247+                 notMembers . add ( netId ) ; 
248+               } 
249+             }  else  { 
250+               remainingNetIds . push ( netId ) ; 
251+             } 
252+           } ) ; 
253+           netIdsToCheck  =  remainingNetIds ; 
254+         } 
255+ 
256+         if  ( netIdsToCheck . length  ===  0 )  { 
257+           return  reply . send ( { 
258+             members : [ ...members ] . sort ( ) , 
259+             notMembers : [ ...notMembers ] . sort ( ) , 
260+             list : list  ===  "acmpaid"  ? undefined  : list , 
261+           } ) ; 
262+         } 
263+ 
264+         const  cachePipeline  =  fastify . redisClient . pipeline ( ) ; 
265+ 
266+         if  ( list  !==  "acmpaid" )  { 
267+           // can't do batch get on an index. 
268+           const  checkPromises  =  netIdsToCheck . map ( async  ( netId )  =>  { 
269+             const  isMember  =  await  checkExternalMembership ( 
270+               netId , 
271+               list , 
272+               fastify . dynamoClient , 
273+             ) ; 
274+             if  ( isMember )  { 
275+               members . add ( netId ) ; 
276+             }  else  { 
277+               notMembers . add ( netId ) ; 
278+             } 
279+             cachePipeline . set ( 
280+               `membership:${ netId }  :${ list }  ` , 
281+               JSON . stringify ( {  isMember } ) , 
282+               "EX" , 
283+               MEMBER_CACHE_SECONDS , 
284+             ) ; 
285+           } ) ; 
286+           await  Promise . all ( checkPromises ) ; 
287+         }  else  { 
288+           const  BATCH_SIZE  =  100 ; 
289+           const  foundInDynamo  =  new  Set < string > ( ) ; 
290+           for  ( let  i  =  0 ;  i  <  netIdsToCheck . length ;  i  +=  BATCH_SIZE )  { 
291+             const  batch  =  netIdsToCheck . slice ( i ,  i  +  BATCH_SIZE ) ; 
292+             const  command  =  new  BatchGetItemCommand ( { 
293+               RequestItems : { 
294+                 [ genericConfig . MembershipTableName ] : { 
295+                   Keys : batch . map ( ( netId )  => 
296+                     marshall ( {  email : `${ netId }  @illinois.edu`  } ) , 
297+                   ) , 
298+                 } , 
299+               } , 
300+             } ) ; 
301+             const  {  Responses }  =  await  fastify . dynamoClient . send ( command ) ; 
302+             const  items  =  Responses ?. [ genericConfig . MembershipTableName ]  ??  [ ] ; 
303+             for  ( const  item  of  items )  { 
304+               const  {  email }  =  unmarshall ( item ) ; 
305+               const  netId  =  email . split ( "@" ) [ 0 ] ; 
306+               members . add ( netId ) ; 
307+               foundInDynamo . add ( netId ) ; 
308+               cachePipeline . set ( 
309+                 `membership:${ netId }  :${ list }  ` , 
310+                 JSON . stringify ( {  isMember : true  } ) , 
311+                 "EX" , 
312+                 MEMBER_CACHE_SECONDS , 
313+               ) ; 
314+             } 
315+           } 
316+ 
317+           // 3. Fallback to Entra ID for remaining paid members 
318+           const  netIdsForEntra  =  netIdsToCheck . filter ( 
319+             ( id )  =>  ! foundInDynamo . has ( id ) , 
320+           ) ; 
321+           if  ( netIdsForEntra . length  >  0 )  { 
322+             const  entraIdToken  =  await  getEntraIdToken ( { 
323+               clients : await  getAuthorizedClients ( ) , 
324+               clientId : fastify . environmentConfig . AadValidClientId , 
325+               secretName : genericConfig . EntraSecretName , 
326+               logger : request . log , 
327+             } ) ; 
328+             const  paidMemberGroup  =  fastify . environmentConfig . PaidMemberGroupId ; 
329+             const  entraCheckPromises  =  netIdsForEntra . map ( async  ( netId )  =>  { 
330+               const  isMember  =  await  checkPaidMembershipFromEntra ( 
331+                 netId , 
332+                 entraIdToken , 
333+                 paidMemberGroup , 
334+               ) ; 
335+               if  ( isMember )  { 
336+                 members . add ( netId ) ; 
337+                 // Fire-and-forget writeback to DynamoDB to warm it up 
338+                 setPaidMembershipInTable ( netId ,  fastify . dynamoClient ) . catch ( 
339+                   ( err )  => 
340+                     request . log . error ( 
341+                       err , 
342+                       `Failed to write back Entra membership for ${ netId }  ` , 
343+                     ) , 
344+                 ) ; 
345+               }  else  { 
346+                 notMembers . add ( netId ) ; 
347+               } 
348+               cachePipeline . set ( 
349+                 `membership:${ netId }  :${ list }  ` , 
350+                 JSON . stringify ( {  isMember } ) , 
351+                 "EX" , 
352+                 MEMBER_CACHE_SECONDS , 
353+               ) ; 
354+             } ) ; 
355+             await  Promise . all ( entraCheckPromises ) ; 
356+           } 
357+         } 
358+ 
359+         if  ( cachePipeline . length  >  0 )  { 
360+           await  cachePipeline . exec ( ) ; 
361+         } 
362+ 
363+         return  reply . send ( { 
364+           members : [ ...members ] . sort ( ) , 
365+           notMembers : [ ...notMembers ] . sort ( ) , 
366+           list : list  ===  "acmpaid"  ? undefined  : list , 
367+         } ) ; 
368+       } , 
369+     ) ; 
370+ 
163371    fastify . withTypeProvider < FastifyZodOpenApiTypeProvider > ( ) . get ( 
164372      "/:netId" , 
165373      { 
0 commit comments